@webpresso/agent-kit 3.3.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/agent/rules/pre-implementation.md +15 -0
- package/dist/esm/cli/commands/blueprint/abandon.d.ts +21 -0
- package/dist/esm/cli/commands/blueprint/abandon.js +178 -0
- package/dist/esm/cli/commands/blueprint/router-dispatch.js +21 -0
- package/dist/esm/cli/commands/blueprint/router-output.js +1 -0
- package/dist/esm/cli/commands/blueprint/router.d.ts +2 -0
- package/dist/esm/cli/commands/blueprint/router.js +5 -2
- package/dist/esm/cli/commands/init/convergence-apply.d.ts +28 -5
- package/dist/esm/cli/commands/init/convergence-apply.js +75 -13
- package/dist/esm/cli/commands/opencode-probe.d.ts +11 -0
- package/dist/esm/cli/commands/opencode-probe.js +1 -1
- package/dist/esm/cli/commands/quality-runner.js +6 -2
- package/dist/esm/cli/commands/typecheck.js +1 -1
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +8 -0
- package/dist/esm/cli/commands/worktree/router-dispatch.js +25 -1
- package/dist/esm/review/execution/review-checkout.d.ts +6 -0
- package/dist/esm/review/execution/review-checkout.js +30 -4
- package/dist/esm/worktrees/owner-dirt.d.ts +40 -0
- package/dist/esm/worktrees/owner-dirt.js +104 -0
- package/package.json +12 -12
|
@@ -320,6 +320,21 @@ checkout target. Local cleanup and primary resync cannot run from GitHub Actions
|
|
|
320
320
|
do not claim landing complete until this cleanup succeeds or is recorded as a
|
|
321
321
|
blocker with evidence.
|
|
322
322
|
|
|
323
|
+
**Owner dirt precondition:** the owner worktree must have empty
|
|
324
|
+
`git status --porcelain` before merge-cleanup. If you ran `wp blueprint new` and
|
|
325
|
+
left `blueprints/draft/<slug>/` uncommitted (or staged only), either:
|
|
326
|
+
|
|
327
|
+
1. **Land the draft** — include `blueprints/draft/<slug>/` (and complete the
|
|
328
|
+
lifecycle) in the PR, or
|
|
329
|
+
2. **Abandon the scaffold** — `wp blueprint abandon <slug> --confirm` (preview
|
|
330
|
+
without `--confirm`), then re-run merge-cleanup.
|
|
331
|
+
|
|
332
|
+
`Blueprint-exempt` is for work that **never created a blueprint**. Do not
|
|
333
|
+
scaffold a draft only to exempt it. For multi-file exempt follow-ups, use a
|
|
334
|
+
**non-`bp/`** worktree prefix (`exempt/<name>` or `chore/<name>`) via
|
|
335
|
+
`wp worktree new` — never `bp/<slug>`, which pre-claims the owner branch and
|
|
336
|
+
collides with `wp blueprint start`.
|
|
337
|
+
|
|
323
338
|
When the primary checkout has user-owned edits or is still on a topic branch,
|
|
324
339
|
prefer:
|
|
325
340
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface AbandonBlueprintOptions {
|
|
2
|
+
readonly projectRoot?: string;
|
|
3
|
+
readonly confirm?: boolean;
|
|
4
|
+
/** Injected for tests. */
|
|
5
|
+
readonly resolveOwnerPath?: (repoRoot: string, slug: string) => string;
|
|
6
|
+
readonly readPorcelainZ?: (cwd: string) => string;
|
|
7
|
+
readonly hasCommittedDraft?: (cwd: string, slug: string) => boolean;
|
|
8
|
+
readonly isPrimaryCheckout?: (ownerPath: string, repoRoot: string) => boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface AbandonBlueprintResult {
|
|
11
|
+
readonly slug: string;
|
|
12
|
+
readonly ownerPath: string;
|
|
13
|
+
readonly preview: boolean;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Abandon an uncommitted draft scaffold so merge-cleanup can proceed.
|
|
19
|
+
* Without --confirm: print preview and throw (exit non-zero at CLI).
|
|
20
|
+
*/
|
|
21
|
+
export declare function abandonBlueprint(rawSlug: string, options?: AbandonBlueprintOptions): Promise<AbandonBlueprintResult>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, lstatSync, rmSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { isValidBlueprintSlug, relativeBlueprintSlug } from "#lifecycle/local.js";
|
|
5
|
+
import { resolveProjectRoot } from "#mcp/tools/_shared/project-root.js";
|
|
6
|
+
import { classifyOwnerDirt, draftScaffoldPrefix, formatBoundedPaths, } from "#worktrees/owner-dirt.js";
|
|
7
|
+
import { resolveOwnerWorktreePath } from "#worktrees/location.js";
|
|
8
|
+
function gitStdout(cwd, args) {
|
|
9
|
+
const result = spawnSync("git", [...args], { cwd, encoding: "utf8" });
|
|
10
|
+
if (result.status !== 0) {
|
|
11
|
+
const err = String(result.stderr ?? result.stdout ?? "git failed").trim();
|
|
12
|
+
throw new Error(err || `git ${args.join(" ")} failed`);
|
|
13
|
+
}
|
|
14
|
+
return String(result.stdout ?? "");
|
|
15
|
+
}
|
|
16
|
+
function defaultReadPorcelainZ(cwd) {
|
|
17
|
+
return gitStdout(cwd, [
|
|
18
|
+
"status",
|
|
19
|
+
"--porcelain=v1",
|
|
20
|
+
"--untracked-files=all",
|
|
21
|
+
"--ignored=no",
|
|
22
|
+
"-z",
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
function defaultHasCommittedDraft(cwd, slug) {
|
|
26
|
+
const prefix = draftScaffoldPrefix(slug);
|
|
27
|
+
const mergeBase = spawnSync("git", ["merge-base", "HEAD", "origin/main"], {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
});
|
|
31
|
+
const range = mergeBase.status === 0 && String(mergeBase.stdout ?? "").trim()
|
|
32
|
+
? `${String(mergeBase.stdout).trim()}..HEAD`
|
|
33
|
+
: "HEAD";
|
|
34
|
+
const log = spawnSync("git", ["log", "--oneline", range, "--", prefix], {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
});
|
|
38
|
+
if (log.status !== 0) {
|
|
39
|
+
// Fail closed: if history probe fails, do not delete.
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return String(log.stdout ?? "").trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
function assertPathInsideDraftRoot(ownerPath, draftAbs) {
|
|
45
|
+
const draftRoot = resolve(ownerPath, "blueprints", "draft") + sep;
|
|
46
|
+
const target = resolve(draftAbs);
|
|
47
|
+
if (target !== resolve(ownerPath, "blueprints", "draft") && !target.startsWith(draftRoot)) {
|
|
48
|
+
throw new Error(`Refusing abandon: path escapes blueprints/draft/ (${target}). next: inspect worktree layout`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isOwnerPathForSlug(path, slug) {
|
|
52
|
+
const normalized = path.replace(/\\/gu, "/");
|
|
53
|
+
return (normalized.endsWith(`/blueprints/${slug}/owner`) ||
|
|
54
|
+
normalized.endsWith(`/blueprints/${slug}/owner/`));
|
|
55
|
+
}
|
|
56
|
+
function resolveOwnerForSlug(repoRoot, slug, resolveOwnerPath) {
|
|
57
|
+
const cwd = resolve(repoRoot);
|
|
58
|
+
// Already inside the owner worktree for this slug (typical agent cwd).
|
|
59
|
+
if (isOwnerPathForSlug(cwd, slug)) {
|
|
60
|
+
return cwd;
|
|
61
|
+
}
|
|
62
|
+
const ownerPath = resolve(resolveOwnerPath(repoRoot, slug));
|
|
63
|
+
if (ownerPath === cwd) {
|
|
64
|
+
throw new Error(`Refusing abandon: resolved owner is the primary checkout (${ownerPath}). next: abandon only from a bp/<slug> owner worktree, or remove primary draft manually after review`);
|
|
65
|
+
}
|
|
66
|
+
if (!isOwnerPathForSlug(ownerPath, slug)) {
|
|
67
|
+
throw new Error(`Refusing abandon: worktree ${ownerPath} is not the owner path for ${slug}. next: run from bp/${slug} owner or pass the correct project root`);
|
|
68
|
+
}
|
|
69
|
+
return ownerPath;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Abandon an uncommitted draft scaffold so merge-cleanup can proceed.
|
|
73
|
+
* Without --confirm: print preview and throw (exit non-zero at CLI).
|
|
74
|
+
*/
|
|
75
|
+
export async function abandonBlueprint(rawSlug, options = {}) {
|
|
76
|
+
const slug = relativeBlueprintSlug(rawSlug.trim());
|
|
77
|
+
if (!isValidBlueprintSlug(slug)) {
|
|
78
|
+
throw new Error(`Refusing abandon: invalid blueprint slug ${JSON.stringify(rawSlug)}. next: use a kebab-case slug like my-feature`);
|
|
79
|
+
}
|
|
80
|
+
const projectRoot = options.projectRoot
|
|
81
|
+
? resolveProjectRoot({ cwd: options.projectRoot, explicitCwd: options.projectRoot })
|
|
82
|
+
: resolveProjectRoot();
|
|
83
|
+
const resolveOwnerPath = options.resolveOwnerPath ?? resolveOwnerWorktreePath;
|
|
84
|
+
const readPorcelainZ = options.readPorcelainZ ?? defaultReadPorcelainZ;
|
|
85
|
+
const hasCommittedDraft = options.hasCommittedDraft ?? defaultHasCommittedDraft;
|
|
86
|
+
const ownerPath = resolveOwnerForSlug(projectRoot, slug, resolveOwnerPath);
|
|
87
|
+
const draftRel = draftScaffoldPrefix(slug);
|
|
88
|
+
const draftAbs = join(ownerPath, draftRel);
|
|
89
|
+
assertPathInsideDraftRoot(ownerPath, draftAbs);
|
|
90
|
+
if (existsSync(draftAbs)) {
|
|
91
|
+
const st = lstatSync(draftAbs);
|
|
92
|
+
if (st.isSymbolicLink()) {
|
|
93
|
+
// Only remove the link itself when confirmed — never follow.
|
|
94
|
+
if (options.confirm !== true) {
|
|
95
|
+
throw new Error([
|
|
96
|
+
`Would remove symlink draft scaffold ${draftRel} in ${ownerPath}`,
|
|
97
|
+
"Re-run with --confirm to remove the symlink only (not the target).",
|
|
98
|
+
].join("\n"));
|
|
99
|
+
}
|
|
100
|
+
rmSync(draftAbs, { force: true });
|
|
101
|
+
const afterLink = classifyOwnerDirt(readPorcelainZ(ownerPath), slug);
|
|
102
|
+
if (afterLink.kind !== "clean") {
|
|
103
|
+
throw new Error(`Abandon incomplete; remaining dirt:\n${formatBoundedPaths(afterLink.paths)}\nnext: git status in ${ownerPath}`);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
slug,
|
|
107
|
+
ownerPath,
|
|
108
|
+
preview: false,
|
|
109
|
+
paths: [draftRel],
|
|
110
|
+
message: `Abandoned draft symlink scaffold for ${slug}; worktree clean — next: wp worktree merge-cleanup bp/${slug}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (hasCommittedDraft(ownerPath, slug)) {
|
|
115
|
+
throw new Error([
|
|
116
|
+
`Refusing abandon: commits on this branch touch ${draftRel}.`,
|
|
117
|
+
"next: land the draft with the PR, or revert those commits (git revert / interactive rebase) — abandon only deletes uncommitted scaffolds",
|
|
118
|
+
].join("\n"));
|
|
119
|
+
}
|
|
120
|
+
const porcelainZ = readPorcelainZ(ownerPath);
|
|
121
|
+
const classification = classifyOwnerDirt(porcelainZ, slug);
|
|
122
|
+
if (classification.kind === "clean" && !existsSync(draftAbs)) {
|
|
123
|
+
throw new Error(`Nothing to abandon for ${slug}: no draft scaffold dirt. next: wp worktree merge-cleanup bp/${slug}`);
|
|
124
|
+
}
|
|
125
|
+
if (classification.kind === "other") {
|
|
126
|
+
throw new Error([
|
|
127
|
+
`Refusing abandon: owner has non-scaffold dirt for ${slug}:`,
|
|
128
|
+
formatBoundedPaths(classification.paths),
|
|
129
|
+
`next: commit or stash non-scaffold paths in ${ownerPath}, then re-run abandon or merge-cleanup`,
|
|
130
|
+
].join("\n"));
|
|
131
|
+
}
|
|
132
|
+
// scaffold-only or clean-but-empty-dir leftover with no porcelain (empty dirs invisible)
|
|
133
|
+
const paths = classification.paths.length > 0 ? classification.paths : existsSync(draftAbs) ? [draftRel] : [];
|
|
134
|
+
if (options.confirm !== true) {
|
|
135
|
+
throw new Error([
|
|
136
|
+
`Would abandon draft scaffold for ${slug} in ${ownerPath}:`,
|
|
137
|
+
formatBoundedPaths(paths.length > 0 ? paths : [draftRel]),
|
|
138
|
+
"Re-run with --confirm to delete. next: wp blueprint abandon <slug> --confirm",
|
|
139
|
+
].join("\n"));
|
|
140
|
+
}
|
|
141
|
+
// Unstage any index entries under the scaffold, then delete the tree.
|
|
142
|
+
const cached = spawnSync("git", ["rm", "-r", "--cached", "--ignore-unmatch", "--", draftRel], {
|
|
143
|
+
cwd: ownerPath,
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
});
|
|
146
|
+
if (cached.status !== 0 && cached.status !== null) {
|
|
147
|
+
// ignore-unmatch should make this soft; still fail closed on hard errors
|
|
148
|
+
const err = String(cached.stderr ?? "").trim();
|
|
149
|
+
if (err && !/did not match/iu.test(err)) {
|
|
150
|
+
throw new Error(`git rm --cached failed: ${err}. next: git status in ${ownerPath}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (existsSync(draftAbs)) {
|
|
154
|
+
assertPathInsideDraftRoot(ownerPath, draftAbs);
|
|
155
|
+
const st = lstatSync(draftAbs);
|
|
156
|
+
if (st.isSymbolicLink()) {
|
|
157
|
+
rmSync(draftAbs, { force: true });
|
|
158
|
+
}
|
|
159
|
+
else if (st.isDirectory() || st.isFile()) {
|
|
160
|
+
rmSync(draftAbs, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const after = classifyOwnerDirt(readPorcelainZ(ownerPath), slug);
|
|
164
|
+
if (after.kind !== "clean") {
|
|
165
|
+
throw new Error([
|
|
166
|
+
`Abandon incomplete; remaining dirt:`,
|
|
167
|
+
formatBoundedPaths(after.paths),
|
|
168
|
+
`next: git status in ${ownerPath}`,
|
|
169
|
+
].join("\n"));
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
slug,
|
|
173
|
+
ownerPath,
|
|
174
|
+
preview: false,
|
|
175
|
+
paths,
|
|
176
|
+
message: `Abandoned draft scaffold for ${slug}; worktree clean — next: wp worktree merge-cleanup bp/${slug}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -6,6 +6,7 @@ import { executeBlueprintDbSubcommand } from "./db-commands.js";
|
|
|
6
6
|
import { getBlueprintSubcommandNames } from "./router-output.js";
|
|
7
7
|
import { listTemplates, resolveTemplate } from "./template-resolver.js";
|
|
8
8
|
import { TRUST_DOSSIER_SCAFFOLD } from "#trust/scaffold.js";
|
|
9
|
+
import { abandonBlueprint } from "./abandon.js";
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Platform template fetcher (injectable for tests)
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -261,6 +262,26 @@ export async function executeBlueprintSubcommand(subcommand, args, options, deps
|
|
|
261
262
|
deps.printBlueprintOutput(options.json ? result : result.message, options.json);
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
265
|
+
case "abandon": {
|
|
266
|
+
const slug = args[0];
|
|
267
|
+
if (!slug) {
|
|
268
|
+
throw new Error("Usage: wp blueprint abandon <slug> --confirm");
|
|
269
|
+
}
|
|
270
|
+
const result = await abandonBlueprint(slug, {
|
|
271
|
+
projectRoot: options.projectRoot,
|
|
272
|
+
confirm: options.confirm === true,
|
|
273
|
+
});
|
|
274
|
+
deps.printBlueprintOutput(options.json
|
|
275
|
+
? {
|
|
276
|
+
slug: result.slug,
|
|
277
|
+
ownerPath: result.ownerPath,
|
|
278
|
+
preview: result.preview,
|
|
279
|
+
paths: result.paths,
|
|
280
|
+
message: result.message,
|
|
281
|
+
}
|
|
282
|
+
: result.message, options.json);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
264
285
|
case "finalize": {
|
|
265
286
|
const slug = args[0];
|
|
266
287
|
if (!slug) {
|
|
@@ -42,6 +42,8 @@ export interface BlueprintCommandOptions extends BlueprintAuditOptions, Blueprin
|
|
|
42
42
|
template?: string;
|
|
43
43
|
templatesDir?: string;
|
|
44
44
|
to?: string;
|
|
45
|
+
/** Required for destructive `blueprint abandon`. */
|
|
46
|
+
confirm?: boolean;
|
|
45
47
|
"--": string[];
|
|
46
48
|
}
|
|
47
49
|
export type { AdvanceTaskResult, PromoteBlueprintResult };
|
|
@@ -175,9 +175,11 @@ export async function createBlueprint(goal, options = {}) {
|
|
|
175
175
|
: probeService;
|
|
176
176
|
// Single full compile lives inside create() on the write root.
|
|
177
177
|
const created = await writeService.create({ complexity, goal, type });
|
|
178
|
+
const draftHint = "Draft is uncommitted — include blueprints/draft/" +
|
|
179
|
+
`${created.slug}/ in your first commit, or run: wp blueprint abandon ${created.slug} --confirm before merge-cleanup.`;
|
|
178
180
|
const message = authoring.routed
|
|
179
|
-
? `Created ${created.type} draft/${created.slug} (routed from primary-like checkout)
|
|
180
|
-
: `Created ${created.type} draft/${created.slug}
|
|
181
|
+
? `Created ${created.type} draft/${created.slug} (routed from primary-like checkout).\n${draftHint}`
|
|
182
|
+
: `Created ${created.type} draft/${created.slug}.\n${draftHint}`;
|
|
181
183
|
return {
|
|
182
184
|
...created,
|
|
183
185
|
message,
|
|
@@ -541,6 +543,7 @@ export function registerBlueprintRouter(cli) {
|
|
|
541
543
|
.option("--template <name>", "Template name to scaffold new blueprint from (see --list-templates)")
|
|
542
544
|
.option("--list-templates", "List available template names and exit")
|
|
543
545
|
.option("--templates-dir <path>", "Override the templates directory (default: docs/templates/)")
|
|
546
|
+
.option("--confirm", "Confirm destructive blueprint abandon")
|
|
544
547
|
.action(async (subcommand, args, options) => {
|
|
545
548
|
try {
|
|
546
549
|
await executeBlueprintSubcommand(subcommand, args, options, {
|
|
@@ -12,6 +12,16 @@ export declare const GIT_PROBE_TIMEOUT_MS = 15000;
|
|
|
12
12
|
* raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
|
|
13
13
|
*/
|
|
14
14
|
export declare const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600000;
|
|
15
|
+
/** Max stdout+stderr retained from probe git subprocesses (Node spawnSync maxBuffer). */
|
|
16
|
+
export declare const GIT_MAX_BUFFER: number;
|
|
17
|
+
/**
|
|
18
|
+
* Max stdout+stderr for the single hookful `git commit`. Quiet hooks cost nothing
|
|
19
|
+
* (Node does not preallocate); chatty consumer format+audit chains can exceed 1 MiB
|
|
20
|
+
* and would otherwise die with ENOBUFS+SIGTERM mid-commit.
|
|
21
|
+
*/
|
|
22
|
+
export declare const GIT_COMMIT_MAX_BUFFER: number;
|
|
23
|
+
/** True when spawnSync killed the child for maxBuffer / stdio overflow. */
|
|
24
|
+
export declare function isBufferOverflowErrorCode(code: string | null | undefined): boolean;
|
|
15
25
|
/**
|
|
16
26
|
* Resolve the hookful-commit timeout. Preference order:
|
|
17
27
|
* 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
|
|
@@ -38,21 +48,34 @@ export declare function formatCommitInterruptError(options: {
|
|
|
38
48
|
readonly signal: NodeJS.Signals;
|
|
39
49
|
readonly hookOutput?: string;
|
|
40
50
|
}): string;
|
|
51
|
+
/**
|
|
52
|
+
* First-class diagnosis when Node spawnSync kills the child because hook/git
|
|
53
|
+
* output exceeded maxBuffer (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER).
|
|
54
|
+
* Distinct from budget timeout and from external signal kills.
|
|
55
|
+
*/
|
|
56
|
+
export declare function formatCommitBufferError(options: {
|
|
57
|
+
readonly maxBufferBytes: number;
|
|
58
|
+
readonly errorCode?: string | null;
|
|
59
|
+
readonly hookOutput?: string;
|
|
60
|
+
}): string;
|
|
41
61
|
/**
|
|
42
62
|
* Classify a failed hookful `git commit` result.
|
|
43
63
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
64
|
+
* Order (first match wins):
|
|
65
|
+
* 1. `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
|
|
66
|
+
* 2. buffer overflow (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER) → buffer-limit diagnosis
|
|
67
|
+
* 3. non-null `signal` → interrupt diagnosis (names the signal)
|
|
68
|
+
* 4. otherwise → hook/git stderr when present, else spawn errorCode / generic preserved-index
|
|
47
69
|
*
|
|
48
|
-
* Never lead with bare hook "passed" lines for
|
|
70
|
+
* Never lead with bare hook "passed" lines for first-class failure paths.
|
|
49
71
|
*/
|
|
50
72
|
export declare function classifyCommitFailure(result: {
|
|
51
73
|
readonly timedOut: boolean;
|
|
52
74
|
readonly signal: NodeJS.Signals | null;
|
|
53
75
|
readonly stderr: string;
|
|
54
76
|
readonly stdout: string;
|
|
55
|
-
|
|
77
|
+
readonly errorCode?: string | null;
|
|
78
|
+
}, timeoutMs: number, maxBufferBytes?: number): string;
|
|
56
79
|
/**
|
|
57
80
|
* Prefix every line of a setup failure so multi-line timeout diagnoses stay
|
|
58
81
|
* labeled when printed via console.error (avoids a single `wp setup: ` on the
|
|
@@ -14,25 +14,44 @@ export const GIT_PROBE_TIMEOUT_MS = 15_000;
|
|
|
14
14
|
* raise/lower via `WP_SETUP_COMMIT_TIMEOUT_MS` or `AuthorCommitOptions.commitTimeoutMs`.
|
|
15
15
|
*/
|
|
16
16
|
export const DEFAULT_GIT_COMMIT_TIMEOUT_MS = 600_000;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** Max stdout+stderr retained from probe git subprocesses (Node spawnSync maxBuffer). */
|
|
18
|
+
export const GIT_MAX_BUFFER = 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Max stdout+stderr for the single hookful `git commit`. Quiet hooks cost nothing
|
|
21
|
+
* (Node does not preallocate); chatty consumer format+audit chains can exceed 1 MiB
|
|
22
|
+
* and would otherwise die with ENOBUFS+SIGTERM mid-commit.
|
|
23
|
+
*/
|
|
24
|
+
export const GIT_COMMIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
25
|
+
/** Hook output snippet size kept in first-class failure diagnoses (tail, not head). */
|
|
26
|
+
const HOOK_OUTPUT_SNIPPET_CHARS = 800;
|
|
27
|
+
/** spawnSync error codes that mean maxBuffer / stdio overflow killed the child. */
|
|
28
|
+
const BUFFER_OVERFLOW_ERROR_CODES = new Set(["ENOBUFS", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]);
|
|
29
|
+
function git(repoRoot, args, timeoutMs, maxBuffer = GIT_MAX_BUFFER) {
|
|
19
30
|
const result = spawnSync("git", [...args], {
|
|
20
31
|
cwd: repoRoot,
|
|
21
32
|
encoding: "utf8",
|
|
22
33
|
// Node: timeout > 0 arms SIGTERM; 0 / omitted means no artificial wall.
|
|
23
34
|
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
24
|
-
maxBuffer
|
|
35
|
+
maxBuffer,
|
|
25
36
|
stdio: ["ignore", "pipe", "pipe"],
|
|
26
37
|
});
|
|
27
|
-
const
|
|
38
|
+
const errorCode = result.error !== undefined && "code" in result.error && result.error.code != null
|
|
39
|
+
? String(result.error.code)
|
|
40
|
+
: null;
|
|
41
|
+
const timedOut = errorCode === "ETIMEDOUT";
|
|
28
42
|
return {
|
|
29
43
|
ok: result.status === 0 && !result.error,
|
|
30
44
|
stdout: (result.stdout ?? "").trim(),
|
|
31
45
|
stderr: (result.stderr ?? "").trim(),
|
|
32
46
|
timedOut,
|
|
33
47
|
signal: result.signal,
|
|
48
|
+
errorCode,
|
|
34
49
|
};
|
|
35
50
|
}
|
|
51
|
+
/** True when spawnSync killed the child for maxBuffer / stdio overflow. */
|
|
52
|
+
export function isBufferOverflowErrorCode(code) {
|
|
53
|
+
return typeof code === "string" && BUFFER_OVERFLOW_ERROR_CODES.has(code);
|
|
54
|
+
}
|
|
36
55
|
/**
|
|
37
56
|
* Resolve the hookful-commit timeout. Preference order:
|
|
38
57
|
* 1. explicit `commitTimeoutMs` option (tests / programmatic callers)
|
|
@@ -60,7 +79,10 @@ function appendHookOutput(diagnosis, hookOutput) {
|
|
|
60
79
|
const trimmed = hookOutput?.trim();
|
|
61
80
|
if (!trimmed)
|
|
62
81
|
return diagnosis;
|
|
63
|
-
|
|
82
|
+
// Keep the tail: failing audits / last lines at kill time matter more than husky banner.
|
|
83
|
+
const snippet = trimmed.length > HOOK_OUTPUT_SNIPPET_CHARS
|
|
84
|
+
? `…${trimmed.slice(-HOOK_OUTPUT_SNIPPET_CHARS)}`
|
|
85
|
+
: trimmed;
|
|
64
86
|
return `${diagnosis}\n--- hook output (truncated) ---\n${snippet}`;
|
|
65
87
|
}
|
|
66
88
|
/** First-class timeout diagnosis; partial hook stdout/stderr is diagnostic only. */
|
|
@@ -87,24 +109,57 @@ export function formatCommitInterruptError(options) {
|
|
|
87
109
|
`Partial hook lines such as "format applied" / "audit … passed" are not success.`;
|
|
88
110
|
return appendHookOutput(diagnosis, options.hookOutput);
|
|
89
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* First-class diagnosis when Node spawnSync kills the child because hook/git
|
|
114
|
+
* output exceeded maxBuffer (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER).
|
|
115
|
+
* Distinct from budget timeout and from external signal kills.
|
|
116
|
+
*/
|
|
117
|
+
export function formatCommitBufferError(options) {
|
|
118
|
+
const code = options.errorCode?.trim() || "ENOBUFS";
|
|
119
|
+
const diagnosis = `Convergence commit failed because git/hook output exceeded the spawnSync maxBuffer limit ` +
|
|
120
|
+
`(${options.maxBufferBytes} bytes; Node error ${code}), so no setup commit was authored. ` +
|
|
121
|
+
`This is a buffer/output-limit failure, not a time budget timeout and not an external kill. ` +
|
|
122
|
+
`Staged and worktree changes were preserved for remediation. ` +
|
|
123
|
+
`Reduce hook verbosity, raise the maxBuffer only with measured need, or fix chatty hooks. ` +
|
|
124
|
+
`Partial hook lines such as "format applied" / "audit … passed" are not success.`;
|
|
125
|
+
return appendHookOutput(diagnosis, options.hookOutput);
|
|
126
|
+
}
|
|
90
127
|
/**
|
|
91
128
|
* Classify a failed hookful `git commit` result.
|
|
92
129
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
130
|
+
* Order (first match wins):
|
|
131
|
+
* 1. `timedOut` (spawnSync ETIMEDOUT) → budget timeout diagnosis
|
|
132
|
+
* 2. buffer overflow (ENOBUFS / ERR_CHILD_PROCESS_STDIO_MAXBUFFER) → buffer-limit diagnosis
|
|
133
|
+
* 3. non-null `signal` → interrupt diagnosis (names the signal)
|
|
134
|
+
* 4. otherwise → hook/git stderr when present, else spawn errorCode / generic preserved-index
|
|
96
135
|
*
|
|
97
|
-
* Never lead with bare hook "passed" lines for
|
|
136
|
+
* Never lead with bare hook "passed" lines for first-class failure paths.
|
|
98
137
|
*/
|
|
99
|
-
export function classifyCommitFailure(result, timeoutMs) {
|
|
138
|
+
export function classifyCommitFailure(result, timeoutMs, maxBufferBytes = GIT_COMMIT_MAX_BUFFER) {
|
|
100
139
|
const hookOutput = [result.stderr, result.stdout].filter(Boolean).join("\n");
|
|
101
140
|
if (result.timedOut) {
|
|
102
141
|
return formatCommitTimeoutError({ timeoutMs, hookOutput });
|
|
103
142
|
}
|
|
143
|
+
if (isBufferOverflowErrorCode(result.errorCode)) {
|
|
144
|
+
return formatCommitBufferError({
|
|
145
|
+
maxBufferBytes,
|
|
146
|
+
errorCode: result.errorCode,
|
|
147
|
+
hookOutput,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
104
150
|
if (result.signal) {
|
|
105
151
|
return formatCommitInterruptError({ signal: result.signal, hookOutput });
|
|
106
152
|
}
|
|
107
|
-
|
|
153
|
+
if (hookOutput) {
|
|
154
|
+
if (result.errorCode) {
|
|
155
|
+
return `${hookOutput}\n(spawn error ${result.errorCode})`;
|
|
156
|
+
}
|
|
157
|
+
return hookOutput;
|
|
158
|
+
}
|
|
159
|
+
if (result.errorCode) {
|
|
160
|
+
return `git commit failed (spawn error ${result.errorCode}); hook changes were preserved.`;
|
|
161
|
+
}
|
|
162
|
+
return "git commit failed; hook changes were preserved.";
|
|
108
163
|
}
|
|
109
164
|
/**
|
|
110
165
|
* Prefix every line of a setup failure so multi-line timeout diagnoses stay
|
|
@@ -230,13 +285,20 @@ export function authorConvergenceCommit(options) {
|
|
|
230
285
|
const commitTimeoutMs = resolveCommitTimeoutMs({
|
|
231
286
|
commitTimeoutMs: options.commitTimeoutMs,
|
|
232
287
|
});
|
|
233
|
-
const
|
|
288
|
+
const budgetLabel = commitTimeoutMs > 0
|
|
289
|
+
? commitTimeoutMs >= 60_000
|
|
290
|
+
? `up to ${Math.round(commitTimeoutMs / 60_000)}m`
|
|
291
|
+
: `up to ${commitTimeoutMs}ms`
|
|
292
|
+
: "no artificial time wall";
|
|
293
|
+
// stderr: setup success path stays machine-friendly on stdout; hooks can take minutes.
|
|
294
|
+
console.error(`wp setup: running repository pre-commit hooks (${budgetLabel})…`);
|
|
295
|
+
const commit = git(options.repoRoot, ["commit", "-m", options.message], commitTimeoutMs, GIT_COMMIT_MAX_BUFFER);
|
|
234
296
|
if (!commit.ok) {
|
|
235
297
|
return {
|
|
236
298
|
status: "failed",
|
|
237
299
|
ok: false,
|
|
238
300
|
allowlist,
|
|
239
|
-
error: classifyCommitFailure(commit, commitTimeoutMs),
|
|
301
|
+
error: classifyCommitFailure(commit, commitTimeoutMs, GIT_COMMIT_MAX_BUFFER),
|
|
240
302
|
};
|
|
241
303
|
}
|
|
242
304
|
const committedHead = probe(["rev-parse", "--verify", "HEAD"]);
|
|
@@ -71,6 +71,17 @@ export interface OpencodeProbeRunnerInput {
|
|
|
71
71
|
}
|
|
72
72
|
export type OpencodeProbeRunner = (input: OpencodeProbeRunnerInput) => Promise<OpencodeProbeResult>;
|
|
73
73
|
export declare function isOpencodeProbeEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Spawn a bounded `opencode run` against one account's isolated credential
|
|
76
|
+
* store and classify the first conclusive thing it says.
|
|
77
|
+
*
|
|
78
|
+
* There is no OpenCode Go HTTP endpoint anywhere in this codebase — the review
|
|
79
|
+
* adapter also reaches the provider only by spawning this binary — so a
|
|
80
|
+
* subprocess is the only available probe vehicle. Per-account isolation is the
|
|
81
|
+
* same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
|
|
82
|
+
* a live interactive TUI's global `auth.json`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function spawnOpencodeProbe(input: OpencodeProbeRunnerInput): Promise<OpencodeProbeResult>;
|
|
74
85
|
export interface ProbeOpencodeAccountDeps {
|
|
75
86
|
readonly run?: OpencodeProbeRunner;
|
|
76
87
|
readonly materialize?: (key: string, dataDir: string) => void;
|
|
@@ -157,7 +157,7 @@ function classifyChunk(captured) {
|
|
|
157
157
|
* same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
|
|
158
158
|
* a live interactive TUI's global `auth.json`.
|
|
159
159
|
*/
|
|
160
|
-
function spawnOpencodeProbe(input) {
|
|
160
|
+
export function spawnOpencodeProbe(input) {
|
|
161
161
|
return new Promise((resolve) => {
|
|
162
162
|
let probeCwd;
|
|
163
163
|
try {
|
|
@@ -145,9 +145,13 @@ function renderSummaryLines(summary, transformed, entry, passed) {
|
|
|
145
145
|
else if (!passed && transformed.rawOutput?.trim()) {
|
|
146
146
|
lines.push(transformed.rawOutput.trimEnd());
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
// Any elided bytes must remain retrievable: fail, clip, OR compaction that
|
|
149
|
+
// recorded an elision without setting truncated. Hide Full log / retrieve
|
|
150
|
+
// hints only when the summary still contains the full content.
|
|
151
|
+
const hasElisions = (transformed.elisions?.length ?? 0) > 0;
|
|
152
|
+
if (!passed || transformed.truncated || hasElisions) {
|
|
149
153
|
lines.push(`Full log: wp logs ${entry.command}`);
|
|
150
|
-
if (
|
|
154
|
+
if (hasElisions) {
|
|
151
155
|
lines.push(...transformed.elisions.map((elision) => `Retrieve elision: ${elision.retrieveTool} id=${elision.id}`));
|
|
152
156
|
}
|
|
153
157
|
}
|
|
@@ -24,7 +24,7 @@ export const TYPECHECK_COMMAND_HELP = [
|
|
|
24
24
|
" wp typecheck --affected --branch # changed vs origin/${GITHUB_BASE_REF:-main}",
|
|
25
25
|
" wp typecheck --pretty",
|
|
26
26
|
" wp typecheck --tests # test-surface lane (tsconfig.test.json); required in",
|
|
27
|
-
" #
|
|
27
|
+
" # ci-preflight and CI (not the default untargeted gate)",
|
|
28
28
|
].join("\n");
|
|
29
29
|
export function registerTypecheckCommand(cli, deps = {}) {
|
|
30
30
|
addAffectedOptions(cli
|
|
@@ -55,6 +55,12 @@ export interface MergeCleanupDecisionInput {
|
|
|
55
55
|
readonly repoDirty: boolean;
|
|
56
56
|
readonly targetDirty: boolean;
|
|
57
57
|
readonly stashPrimary?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* When target is dirty, optional porcelain -z + slug enrich the refusal message
|
|
60
|
+
* (scaffold-only vs other dirt). Omitted messages stay generic for unit tests.
|
|
61
|
+
*/
|
|
62
|
+
readonly targetPorcelainZ?: string;
|
|
63
|
+
readonly targetBlueprintSlug?: string;
|
|
58
64
|
}
|
|
59
65
|
export interface NewWorktreeTargetInput {
|
|
60
66
|
branch?: string;
|
|
@@ -97,4 +103,6 @@ export interface CreateManagedWorktreeResult {
|
|
|
97
103
|
export declare function formatWorktreeList(entries: WorktreeEntry[], currentWorktreePath: string): string[];
|
|
98
104
|
export declare function formatManagedWorktreeList(entries: readonly ManagedWorktreeEntry[]): string[];
|
|
99
105
|
export declare function createManagedWorktree(opts?: CreateManagedWorktreeOptions): Promise<CreateManagedWorktreeResult>;
|
|
106
|
+
/** `.../blueprints/<slug>/owner` → slug */
|
|
107
|
+
export declare function inferBlueprintSlugFromOwnerPath(ownerPath: string): string | undefined;
|
|
100
108
|
export declare function executeWorktreeSubcommand(subcommand: string, args: string[], opts: WorktreeCommandOptions): Promise<void>;
|
|
@@ -15,6 +15,7 @@ import { runWorktreeSetup, runWorktreeTeardown } from "#worktrees/lifecycle-scri
|
|
|
15
15
|
import { hydrateWorktreeDependencies, reconcileWorktreeDependencies, } from "#worktrees/dependencies.js";
|
|
16
16
|
import { projectWorktreeAgentSurfaces } from "#worktrees/project-agent-surfaces.js";
|
|
17
17
|
import { assertNotLocalMainBranch } from "#worktrees/main-ownership.js";
|
|
18
|
+
import { formatTargetDirtyRefusal } from "#worktrees/owner-dirt.js";
|
|
18
19
|
import { buildWorktreeInventory, canonicalizeWorktreePath, classifyMembership, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
|
|
19
20
|
import { findRegistryCandidatesByPath, planStaleWorktreeRegistryPrune, readWorktreeRegistry, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
|
|
20
21
|
import { buildRegistryRepoRootMap, classifyOrphanCandidate, discoverOrphanScanCandidates, resolveRepoRootFromGitPointer, } from "#worktrees/orphan-scan.js";
|
|
@@ -182,6 +183,13 @@ export function decideMergeCleanup(input) {
|
|
|
182
183
|
throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} is locked`);
|
|
183
184
|
}
|
|
184
185
|
if (input.targetDirty) {
|
|
186
|
+
if (input.targetPorcelainZ !== undefined) {
|
|
187
|
+
throw new Error(formatTargetDirtyRefusal({
|
|
188
|
+
targetPath: input.targetPath,
|
|
189
|
+
porcelainZ: input.targetPorcelainZ,
|
|
190
|
+
slug: input.targetBlueprintSlug,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
185
193
|
throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} has uncommitted changes`);
|
|
186
194
|
}
|
|
187
195
|
if (input.repoDirty) {
|
|
@@ -561,6 +569,12 @@ function isDirty(path) {
|
|
|
561
569
|
return true;
|
|
562
570
|
return String(status.stdout ?? "").trim().length > 0;
|
|
563
571
|
}
|
|
572
|
+
/** `.../blueprints/<slug>/owner` → slug */
|
|
573
|
+
export function inferBlueprintSlugFromOwnerPath(ownerPath) {
|
|
574
|
+
const normalized = ownerPath.replace(/\\/gu, "/");
|
|
575
|
+
const match = /\/blueprints\/([^/]+)\/owner\/?$/u.exec(normalized);
|
|
576
|
+
return match?.[1];
|
|
577
|
+
}
|
|
564
578
|
function currentBranch(repoRoot) {
|
|
565
579
|
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
566
580
|
cwd: repoRoot,
|
|
@@ -691,6 +705,14 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
691
705
|
const scopedRegistryEntries = findRegistryCandidatesByPath(resolved)
|
|
692
706
|
.filter((candidate) => classifyMembership(candidate.entry.repoRoot, mergeAuthKey) === "same")
|
|
693
707
|
.map((candidate) => candidate.entry);
|
|
708
|
+
const targetDirty = isDirty(resolved);
|
|
709
|
+
const targetPorcelainZ = targetDirty
|
|
710
|
+
? (() => {
|
|
711
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no", "-z"], { cwd: resolved, encoding: "utf8" });
|
|
712
|
+
return status.status === 0 ? String(status.stdout ?? "") : "?? (git-status-failed)\0";
|
|
713
|
+
})()
|
|
714
|
+
: undefined;
|
|
715
|
+
const targetBlueprintSlug = inferBlueprintSlugFromOwnerPath(resolved);
|
|
694
716
|
const plan = decideMergeCleanup({
|
|
695
717
|
repoRoot,
|
|
696
718
|
targetPath: resolved,
|
|
@@ -699,8 +721,10 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
699
721
|
registryEntries: scopedRegistryEntries,
|
|
700
722
|
currentBranch: currentBranch(repoRoot),
|
|
701
723
|
repoDirty: isDirty(repoRoot),
|
|
702
|
-
targetDirty
|
|
724
|
+
targetDirty,
|
|
703
725
|
stashPrimary: opts.stashPrimary === true,
|
|
726
|
+
...(targetPorcelainZ !== undefined ? { targetPorcelainZ } : {}),
|
|
727
|
+
...(targetBlueprintSlug !== undefined ? { targetBlueprintSlug } : {}),
|
|
704
728
|
});
|
|
705
729
|
if (opts.dryRun) {
|
|
706
730
|
console.log("[dry-run] Would run merge cleanup:");
|
|
@@ -4,4 +4,10 @@ export interface ReviewTargetCheckout {
|
|
|
4
4
|
readonly changes: () => readonly string[];
|
|
5
5
|
readonly cleanup: () => void;
|
|
6
6
|
}
|
|
7
|
+
declare function statusEntryPath(line: string): string;
|
|
7
8
|
export declare function createReviewTargetCheckout(repositoryRoot: string, targetSha: string): ReviewTargetCheckout;
|
|
9
|
+
/** Test-only surface for porcelain path parsing. */
|
|
10
|
+
export declare const reviewCheckoutInternals: {
|
|
11
|
+
statusEntryPath: typeof statusEntryPath;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -121,11 +121,33 @@ function isNormalizedPackageJsonModification(cwd, line) {
|
|
|
121
121
|
// Single source of truth for turning a `git status --porcelain=v1` line into
|
|
122
122
|
// its relative path, shared by status()'s filter predicate and
|
|
123
123
|
// worktreeFingerprint()'s dirty-path hashing so both stay aligned by
|
|
124
|
-
// construction.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
124
|
+
// construction.
|
|
125
|
+
//
|
|
126
|
+
// Rename/copy lines use non-null porcelain form `XY ORIG -> DEST` (or quoted
|
|
127
|
+
// paths). The destination is the live path whose bytes must be fingerprinted;
|
|
128
|
+
// using the raw `ORIG -> DEST` tail makes lstat miss both sides and silently
|
|
129
|
+
// drops content from the hash (regression vs paired-record `-z` parsing).
|
|
127
130
|
function statusEntryPath(line) {
|
|
128
|
-
|
|
131
|
+
const tail = line.slice(3).replace(/\/$/u, "");
|
|
132
|
+
const renameSep = " -> ";
|
|
133
|
+
const sep = tail.lastIndexOf(renameSep);
|
|
134
|
+
const pathPart = sep === -1 ? tail : tail.slice(sep + renameSep.length);
|
|
135
|
+
return unquotePorcelainPath(pathPart);
|
|
136
|
+
}
|
|
137
|
+
/** Strip git porcelain double-quotes and reverse C-style escapes for a path. */
|
|
138
|
+
function unquotePorcelainPath(pathPart) {
|
|
139
|
+
if (!pathPart.startsWith('"') || !pathPart.endsWith('"') || pathPart.length < 2) {
|
|
140
|
+
return pathPart;
|
|
141
|
+
}
|
|
142
|
+
return pathPart.slice(1, -1).replace(/\\([\\"ntr])/gu, (_m, ch) => {
|
|
143
|
+
if (ch === "n")
|
|
144
|
+
return "\n";
|
|
145
|
+
if (ch === "t")
|
|
146
|
+
return "\t";
|
|
147
|
+
if (ch === "r")
|
|
148
|
+
return "\r";
|
|
149
|
+
return ch;
|
|
150
|
+
});
|
|
129
151
|
}
|
|
130
152
|
function status(cwd, ignoredPaths = new Set()) {
|
|
131
153
|
const output = git(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"]);
|
|
@@ -245,3 +267,7 @@ function projectCallerDependencies(repositoryRoot, checkoutRoot) {
|
|
|
245
267
|
projected.add(dependencyPath);
|
|
246
268
|
return projected;
|
|
247
269
|
}
|
|
270
|
+
/** Test-only surface for porcelain path parsing. */
|
|
271
|
+
export const reviewCheckoutInternals = {
|
|
272
|
+
statusEntryPath,
|
|
273
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-worktree dirt classification for scaffold abandon + merge-cleanup hints.
|
|
3
|
+
*
|
|
4
|
+
* Machine-parse `git status --porcelain=v1 -z` only (NUL records; renames are
|
|
5
|
+
* destination then origin with no `->`). Do not use line-oriented porcelain for
|
|
6
|
+
* path classification — special characters and renames mis-parse.
|
|
7
|
+
*
|
|
8
|
+
* Parity with `isDirty`: ignored files are not considered (no `--ignored`).
|
|
9
|
+
*/
|
|
10
|
+
export type OwnerDirtKind = "clean" | "scaffold-only" | "other";
|
|
11
|
+
export interface OwnerDirtClassification {
|
|
12
|
+
readonly kind: OwnerDirtKind;
|
|
13
|
+
/** Repo-relative paths contributing to dirt (bounded callers may slice). */
|
|
14
|
+
readonly paths: readonly string[];
|
|
15
|
+
/** Paths under the draft scaffold prefix, when slug is provided. */
|
|
16
|
+
readonly scaffoldPaths: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export interface PorcelainPathRecord {
|
|
19
|
+
readonly statusCode: string;
|
|
20
|
+
readonly paths: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/** Blueprint draft tree for a slug (no leading/trailing slash on slug). */
|
|
23
|
+
export declare function draftScaffoldPrefix(slug: string): string;
|
|
24
|
+
export declare function isUnderDraftScaffold(relativePath: string, slug: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Parse `git status --porcelain=v1 -z` stdout into path records.
|
|
27
|
+
* Rename/copy: first path is destination, second (next record) is origin.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parsePorcelainV1Z(output: string): readonly PorcelainPathRecord[];
|
|
30
|
+
/**
|
|
31
|
+
* Classify dirt relative to an optional draft slug.
|
|
32
|
+
* Without slug, any non-empty porcelain is `other` (or `clean` if empty).
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifyOwnerDirt(porcelainZ: string, slug?: string): OwnerDirtClassification;
|
|
35
|
+
export declare function formatBoundedPaths(paths: readonly string[], limit?: number): string;
|
|
36
|
+
export declare function formatTargetDirtyRefusal(input: {
|
|
37
|
+
readonly targetPath: string;
|
|
38
|
+
readonly porcelainZ: string;
|
|
39
|
+
readonly slug?: string;
|
|
40
|
+
}): string;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-worktree dirt classification for scaffold abandon + merge-cleanup hints.
|
|
3
|
+
*
|
|
4
|
+
* Machine-parse `git status --porcelain=v1 -z` only (NUL records; renames are
|
|
5
|
+
* destination then origin with no `->`). Do not use line-oriented porcelain for
|
|
6
|
+
* path classification — special characters and renames mis-parse.
|
|
7
|
+
*
|
|
8
|
+
* Parity with `isDirty`: ignored files are not considered (no `--ignored`).
|
|
9
|
+
*/
|
|
10
|
+
/** Blueprint draft tree for a slug (no leading/trailing slash on slug). */
|
|
11
|
+
export function draftScaffoldPrefix(slug) {
|
|
12
|
+
return `blueprints/draft/${slug}`;
|
|
13
|
+
}
|
|
14
|
+
export function isUnderDraftScaffold(relativePath, slug) {
|
|
15
|
+
const prefix = draftScaffoldPrefix(slug);
|
|
16
|
+
return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse `git status --porcelain=v1 -z` stdout into path records.
|
|
20
|
+
* Rename/copy: first path is destination, second (next record) is origin.
|
|
21
|
+
*/
|
|
22
|
+
export function parsePorcelainV1Z(output) {
|
|
23
|
+
const records = output.split("\0").filter((record) => record.length > 0);
|
|
24
|
+
const parsed = [];
|
|
25
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
26
|
+
const record = records[index];
|
|
27
|
+
if (record.length < 3)
|
|
28
|
+
continue;
|
|
29
|
+
const statusCode = record.slice(0, 2);
|
|
30
|
+
const firstPath = record.slice(3);
|
|
31
|
+
const paths = [firstPath];
|
|
32
|
+
if (/[RC]/u.test(statusCode)) {
|
|
33
|
+
const originalPath = records[index + 1];
|
|
34
|
+
if (originalPath !== undefined) {
|
|
35
|
+
paths.push(originalPath);
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
parsed.push({ statusCode, paths });
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Classify dirt relative to an optional draft slug.
|
|
45
|
+
* Without slug, any non-empty porcelain is `other` (or `clean` if empty).
|
|
46
|
+
*/
|
|
47
|
+
export function classifyOwnerDirt(porcelainZ, slug) {
|
|
48
|
+
const records = parsePorcelainV1Z(porcelainZ);
|
|
49
|
+
if (records.length === 0) {
|
|
50
|
+
return { kind: "clean", paths: [], scaffoldPaths: [] };
|
|
51
|
+
}
|
|
52
|
+
const paths = new Set();
|
|
53
|
+
const scaffoldPaths = new Set();
|
|
54
|
+
let allScaffold = slug !== undefined && slug.length > 0;
|
|
55
|
+
for (const record of records) {
|
|
56
|
+
for (const relativePath of record.paths) {
|
|
57
|
+
if (relativePath.length === 0)
|
|
58
|
+
continue;
|
|
59
|
+
paths.add(relativePath);
|
|
60
|
+
if (slug && isUnderDraftScaffold(relativePath, slug)) {
|
|
61
|
+
scaffoldPaths.add(relativePath);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
allScaffold = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const pathList = [...paths].toSorted();
|
|
69
|
+
const scaffoldList = [...scaffoldPaths].toSorted();
|
|
70
|
+
if (pathList.length === 0) {
|
|
71
|
+
return { kind: "clean", paths: [], scaffoldPaths: [] };
|
|
72
|
+
}
|
|
73
|
+
if (allScaffold && scaffoldList.length > 0) {
|
|
74
|
+
return { kind: "scaffold-only", paths: pathList, scaffoldPaths: scaffoldList };
|
|
75
|
+
}
|
|
76
|
+
return { kind: "other", paths: pathList, scaffoldPaths: scaffoldList };
|
|
77
|
+
}
|
|
78
|
+
export function formatBoundedPaths(paths, limit = 10) {
|
|
79
|
+
if (paths.length === 0)
|
|
80
|
+
return "(none)";
|
|
81
|
+
const head = paths.slice(0, limit);
|
|
82
|
+
const more = paths.length - head.length;
|
|
83
|
+
return more > 0 ? `${head.join("\n")}\n(and ${more} more)` : head.join("\n");
|
|
84
|
+
}
|
|
85
|
+
export function formatTargetDirtyRefusal(input) {
|
|
86
|
+
const classification = classifyOwnerDirt(input.porcelainZ, input.slug);
|
|
87
|
+
const pathBlock = formatBoundedPaths(classification.paths);
|
|
88
|
+
const header = `wp worktree merge-cleanup refused: ${input.targetPath} has uncommitted changes`;
|
|
89
|
+
if (classification.kind === "scaffold-only" && input.slug) {
|
|
90
|
+
return [
|
|
91
|
+
header,
|
|
92
|
+
"Owner has only uncommitted draft scaffold dirt:",
|
|
93
|
+
pathBlock,
|
|
94
|
+
`fix: wp blueprint abandon ${input.slug} --confirm`,
|
|
95
|
+
" or: commit the draft and land it with the PR",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
header,
|
|
100
|
+
"Paths:",
|
|
101
|
+
pathBlock,
|
|
102
|
+
`fix: commit or stash work in ${input.targetPath}, then re-run merge-cleanup`,
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpresso/agent-kit",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "TypeScript-first agent harness for guarded develop/deploy workflows: wp CLI gates, MCP tools, hooks, memory, worktrees, secrets, audits, and evidence checks.",
|
|
6
6
|
"keywords": [
|
|
@@ -507,16 +507,16 @@
|
|
|
507
507
|
"setupWpActionRef": "c2c71a7a4be446fc6858e6b57bf55a11ccfa2d88"
|
|
508
508
|
},
|
|
509
509
|
"optionalDependencies": {
|
|
510
|
-
"@webpresso/agent-kit-runtime-darwin-arm64": "3.3.
|
|
511
|
-
"@webpresso/agent-kit-runtime-darwin-x64": "3.3.
|
|
512
|
-
"@webpresso/agent-kit-runtime-linux-x64": "3.3.
|
|
513
|
-
"@webpresso/agent-kit-runtime-linux-arm64": "3.3.
|
|
514
|
-
"@webpresso/agent-kit-runtime-windows-x64": "3.3.
|
|
515
|
-
"@webpresso/agent-kit-session-memory-darwin-x64": "3.3.
|
|
516
|
-
"@webpresso/agent-kit-session-memory-darwin-arm64": "3.3.
|
|
517
|
-
"@webpresso/agent-kit-session-memory-linux-x64": "3.3.
|
|
518
|
-
"@webpresso/agent-kit-session-memory-linux-arm64": "3.3.
|
|
519
|
-
"@webpresso/agent-kit-session-memory-win32-x64": "3.3.
|
|
520
|
-
"@webpresso/agent-kit-session-memory-win32-arm64": "3.3.
|
|
510
|
+
"@webpresso/agent-kit-runtime-darwin-arm64": "3.3.1",
|
|
511
|
+
"@webpresso/agent-kit-runtime-darwin-x64": "3.3.1",
|
|
512
|
+
"@webpresso/agent-kit-runtime-linux-x64": "3.3.1",
|
|
513
|
+
"@webpresso/agent-kit-runtime-linux-arm64": "3.3.1",
|
|
514
|
+
"@webpresso/agent-kit-runtime-windows-x64": "3.3.1",
|
|
515
|
+
"@webpresso/agent-kit-session-memory-darwin-x64": "3.3.1",
|
|
516
|
+
"@webpresso/agent-kit-session-memory-darwin-arm64": "3.3.1",
|
|
517
|
+
"@webpresso/agent-kit-session-memory-linux-x64": "3.3.1",
|
|
518
|
+
"@webpresso/agent-kit-session-memory-linux-arm64": "3.3.1",
|
|
519
|
+
"@webpresso/agent-kit-session-memory-win32-x64": "3.3.1",
|
|
520
|
+
"@webpresso/agent-kit-session-memory-win32-arm64": "3.3.1"
|
|
521
521
|
}
|
|
522
522
|
}
|