@webpresso/agent-kit 3.3.4 → 3.3.6
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.
Potentially problematic release.
This version of @webpresso/agent-kit might be problematic. Click here for more details.
- package/dist/esm/audit/agents.js +126 -26
- package/dist/esm/blueprint/trust/dossier.js +41 -1
- package/dist/esm/blueprint/verification.d.ts +12 -1
- package/dist/esm/blueprint/verification.js +26 -3
- package/dist/esm/build/vendor-oxlint-plugins.d.ts +4 -1
- package/dist/esm/build/vendor-oxlint-plugins.js +9 -14
- package/dist/esm/cli/commands/init/index.js +3 -4
- package/dist/esm/cli/commands/init/scaffold-base-kit.js +5 -1
- package/dist/esm/cli/commands/mcp.js +2 -0
- package/dist/esm/cli/commands/review.d.ts +1 -0
- package/dist/esm/cli/commands/review.js +77 -1
- package/dist/esm/cli/commands/sync.d.ts +8 -6
- package/dist/esm/cli/commands/sync.js +19 -9
- package/dist/esm/config/oxlint/oxlintrc.d.ts +1 -1
- package/dist/esm/config/oxlint/oxlintrc.js +3 -12
- package/dist/esm/content/dispatch.js +4 -0
- package/dist/esm/content/loader.d.ts +1 -1
- package/dist/esm/content/loader.js +10 -6
- package/dist/esm/content/skill-policy.d.ts +11 -0
- package/dist/esm/content/skill-policy.js +33 -0
- package/dist/esm/hooks/doctor.js +114 -10
- package/dist/esm/hooks/pretool-guard/dev-routing.js +67 -4
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
- package/dist/esm/mcp/blueprint/_shared/lifecycle.d.ts +13 -0
- package/dist/esm/mcp/blueprint/_shared/lifecycle.js +40 -8
- package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +97 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +23 -3
- package/dist/esm/mcp/blueprint/handlers/finalize.js +18 -7
- package/dist/esm/mcp/blueprint/registration.js +31 -2
- package/dist/esm/review/authority.js +56 -1
- package/dist/esm/review/delivery-verifier.d.ts +4 -0
- package/dist/esm/review/delivery-verifier.js +9 -1
- package/dist/esm/symlinker/consumers.d.ts +20 -1
- package/dist/esm/symlinker/consumers.js +15 -1
- package/dist/esm/symlinker/ownership.d.ts +48 -0
- package/dist/esm/symlinker/ownership.js +83 -0
- package/dist/esm/symlinker/unified-sync.d.ts +35 -3
- package/dist/esm/symlinker/unified-sync.js +112 -14
- package/dist/esm/test/shard-durations.json +0 -1
- package/package.json +12 -12
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
|
@@ -1,5 +1,80 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import type { ProjectResolver } from "#project-resolver.js";
|
|
2
3
|
import type { ToolHandlerResult } from "#mcp/auto-discover.js";
|
|
4
|
+
declare const putDocumentSchema: z.ZodObject<{
|
|
5
|
+
type: z.ZodDefault<z.ZodLiteral<"blueprint">>;
|
|
6
|
+
title: z.ZodString;
|
|
7
|
+
status: z.ZodEnum<{
|
|
8
|
+
archived: "archived";
|
|
9
|
+
completed: "completed";
|
|
10
|
+
draft: "draft";
|
|
11
|
+
"in-progress": "in-progress";
|
|
12
|
+
parked: "parked";
|
|
13
|
+
planned: "planned";
|
|
14
|
+
}>;
|
|
15
|
+
complexity: z.ZodEnum<{
|
|
16
|
+
L: "L";
|
|
17
|
+
M: "M";
|
|
18
|
+
S: "S";
|
|
19
|
+
XL: "XL";
|
|
20
|
+
XS: "XS";
|
|
21
|
+
}>;
|
|
22
|
+
owner: z.ZodString;
|
|
23
|
+
created: z.ZodString;
|
|
24
|
+
last_updated: z.ZodString;
|
|
25
|
+
progress: z.ZodOptional<z.ZodString>;
|
|
26
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
27
|
+
product_wedge_anchor: z.ZodObject<{
|
|
28
|
+
stage_outcome: z.ZodString;
|
|
29
|
+
consuming_surface: z.ZodString;
|
|
30
|
+
new_user_visible_capability: z.ZodString;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
summary: z.ZodString;
|
|
33
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
34
|
+
id: z.ZodString;
|
|
35
|
+
title: z.ZodString;
|
|
36
|
+
status: z.ZodDefault<z.ZodEnum<{
|
|
37
|
+
blocked: "blocked";
|
|
38
|
+
done: "done";
|
|
39
|
+
dropped: "dropped";
|
|
40
|
+
"in-progress": "in-progress";
|
|
41
|
+
todo: "todo";
|
|
42
|
+
}>>;
|
|
43
|
+
wave: z.ZodOptional<z.ZodString>;
|
|
44
|
+
lane: z.ZodOptional<z.ZodString>;
|
|
45
|
+
description: z.ZodOptional<z.ZodString>;
|
|
46
|
+
acceptance: z.ZodArray<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>>;
|
|
48
|
+
trust_dossier: z.ZodOptional<z.ZodObject<{
|
|
49
|
+
readiness: z.ZodObject<{
|
|
50
|
+
promotion_ready: z.ZodBoolean;
|
|
51
|
+
unresolved_count: z.ZodNumber;
|
|
52
|
+
verified_at: z.ZodString;
|
|
53
|
+
verified_head: z.ZodString;
|
|
54
|
+
trust_gate_version: z.ZodString;
|
|
55
|
+
}, z.core.$strip>;
|
|
56
|
+
material_claims: z.ZodArray<z.ZodObject<{
|
|
57
|
+
id: z.ZodString;
|
|
58
|
+
claim: z.ZodString;
|
|
59
|
+
evidence: z.ZodString;
|
|
60
|
+
}, z.core.$strip>>;
|
|
61
|
+
material_decisions: z.ZodArray<z.ZodObject<{
|
|
62
|
+
id: z.ZodString;
|
|
63
|
+
decision: z.ZodString;
|
|
64
|
+
chosen_option: z.ZodString;
|
|
65
|
+
rejected_alternatives: z.ZodString;
|
|
66
|
+
rationale: z.ZodString;
|
|
67
|
+
}, z.core.$strip>>;
|
|
68
|
+
promotion_gates: z.ZodArray<z.ZodObject<{
|
|
69
|
+
gate: z.ZodString;
|
|
70
|
+
command: z.ZodString;
|
|
71
|
+
expected_outcome: z.ZodString;
|
|
72
|
+
last_result: z.ZodString;
|
|
73
|
+
defer: z.ZodOptional<z.ZodLiteral<"pre-implementation">>;
|
|
74
|
+
}, z.core.$strip>>;
|
|
75
|
+
residual_unknowns: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
76
|
+
}, z.core.$strip>>;
|
|
77
|
+
}, z.core.$strip>;
|
|
3
78
|
export declare const putInputJsonSchema: {
|
|
4
79
|
type: string;
|
|
5
80
|
properties: {
|
|
@@ -233,6 +308,28 @@ export declare const putInputJsonSchema: {
|
|
|
233
308
|
};
|
|
234
309
|
required: string[];
|
|
235
310
|
};
|
|
311
|
+
/**
|
|
312
|
+
* Promotion-grade dossier completeness (promotion-ready: true,
|
|
313
|
+
* unresolved-count: 0, Residual Unknowns exactly `None.`, non-empty material
|
|
314
|
+
* claims/decisions/gates, passing gate results) is a PROMOTION-time
|
|
315
|
+
* requirement, not a draft-authoring one. `wp_blueprint_put` only ever writes
|
|
316
|
+
* `status: draft` documents (see the refusal in `handleBlueprintPut` above);
|
|
317
|
+
* gating every intermediate draft save on full promotion readiness forced
|
|
318
|
+
* authors to either fabricate readiness fields or keep genuinely-finished
|
|
319
|
+
* tasks marked `todo` to dodge the "checked box implies unreviewed
|
|
320
|
+
* completion" ambiguity scan -- inverting the draft -> planned lifecycle.
|
|
321
|
+
*
|
|
322
|
+
* `promotionCandidate` is therefore derived from the document's OWN target
|
|
323
|
+
* `status`, not hardcoded: a `draft` target skips promotion-grade dossier
|
|
324
|
+
* completeness entirely (an incomplete/in-progress dossier round-trips
|
|
325
|
+
* as-is), while any non-draft target keeps today's full strictness
|
|
326
|
+
* unchanged. Real promotion strictness is enforced separately and
|
|
327
|
+
* unconditionally at `wp_blueprint_transition`'s `crossesDraftApprovalBoundary`
|
|
328
|
+
* gate (`applyPromotionTrustGate` in `#trust/promotion.ts`), which still
|
|
329
|
+
* hardcodes `promotionCandidate: true` and is untouched by this function.
|
|
330
|
+
*/
|
|
331
|
+
export declare function validatePutTrustDossier(repoRoot: string, file: string, markdown: string, targetStatus: z.infer<typeof putDocumentSchema>["status"]): string | null;
|
|
236
332
|
export declare function handleBlueprintPut(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
|
|
237
333
|
export declare function handleBlueprintTransition(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
|
|
238
334
|
export declare function handleBlueprintCreate(projectResolver: ProjectResolver, cwd: string, raw: unknown): Promise<ToolHandlerResult>;
|
|
335
|
+
export {};
|
|
@@ -427,13 +427,33 @@ function describeEvidenceLossRefusal(taskId, submittedStatus) {
|
|
|
427
427
|
`that evidence. Keep task ${taskId} present with status "done" in the payload; put will carry ` +
|
|
428
428
|
`the recorded evidence forward automatically.`);
|
|
429
429
|
}
|
|
430
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Promotion-grade dossier completeness (promotion-ready: true,
|
|
432
|
+
* unresolved-count: 0, Residual Unknowns exactly `None.`, non-empty material
|
|
433
|
+
* claims/decisions/gates, passing gate results) is a PROMOTION-time
|
|
434
|
+
* requirement, not a draft-authoring one. `wp_blueprint_put` only ever writes
|
|
435
|
+
* `status: draft` documents (see the refusal in `handleBlueprintPut` above);
|
|
436
|
+
* gating every intermediate draft save on full promotion readiness forced
|
|
437
|
+
* authors to either fabricate readiness fields or keep genuinely-finished
|
|
438
|
+
* tasks marked `todo` to dodge the "checked box implies unreviewed
|
|
439
|
+
* completion" ambiguity scan -- inverting the draft -> planned lifecycle.
|
|
440
|
+
*
|
|
441
|
+
* `promotionCandidate` is therefore derived from the document's OWN target
|
|
442
|
+
* `status`, not hardcoded: a `draft` target skips promotion-grade dossier
|
|
443
|
+
* completeness entirely (an incomplete/in-progress dossier round-trips
|
|
444
|
+
* as-is), while any non-draft target keeps today's full strictness
|
|
445
|
+
* unchanged. Real promotion strictness is enforced separately and
|
|
446
|
+
* unconditionally at `wp_blueprint_transition`'s `crossesDraftApprovalBoundary`
|
|
447
|
+
* gate (`applyPromotionTrustGate` in `#trust/promotion.ts`), which still
|
|
448
|
+
* hardcodes `promotionCandidate: true` and is untouched by this function.
|
|
449
|
+
*/
|
|
450
|
+
export function validatePutTrustDossier(repoRoot, file, markdown, targetStatus) {
|
|
431
451
|
const validated = validateBlueprintTrust({
|
|
432
452
|
repoRoot,
|
|
433
453
|
file,
|
|
434
454
|
status: "draft",
|
|
435
455
|
markdown,
|
|
436
|
-
promotionCandidate:
|
|
456
|
+
promotionCandidate: targetStatus !== "draft",
|
|
437
457
|
requirePassingGates: false,
|
|
438
458
|
scanTaskAmbiguity: true,
|
|
439
459
|
});
|
|
@@ -509,7 +529,7 @@ export async function handleBlueprintPut(projectResolver, cwd, raw) {
|
|
|
509
529
|
markdown = preserved.markdown;
|
|
510
530
|
}
|
|
511
531
|
if (document.trust_dossier) {
|
|
512
|
-
const trustDossierFailure = validatePutTrustDossier(writeCwd, overviewPath, markdown);
|
|
532
|
+
const trustDossierFailure = validatePutTrustDossier(writeCwd, overviewPath, markdown, document.status);
|
|
513
533
|
if (trustDossierFailure) {
|
|
514
534
|
return err("wp_blueprint_put validation error", trustDossierFailure);
|
|
515
535
|
}
|
|
@@ -7,7 +7,7 @@ import { ensureProjectionReady } from "#projection-ready.js";
|
|
|
7
7
|
import { resolveBlueprintRoot } from "#utils/blueprint-root.js";
|
|
8
8
|
import { dbPath } from "#mcp/blueprint/_shared/db";
|
|
9
9
|
import { err, finishPayload, jsonContent } from "#mcp/blueprint/_shared/errors";
|
|
10
|
-
import { applyLocalBlueprintTransition,
|
|
10
|
+
import { applyLocalBlueprintTransition, collectBlueprintCompletionBlockers, NON_COMPLETED_BLUEPRINT_STATES, } from "#mcp/blueprint/_shared/lifecycle";
|
|
11
11
|
import { toStr } from "#mcp/blueprint/_shared/payload";
|
|
12
12
|
import { findBlueprintDir, resolveToolProject } from "#mcp/blueprint/_shared/project";
|
|
13
13
|
import { resolveSyncAdapter, runPlatformMutationSync } from "#mcp/blueprint/_shared/sync";
|
|
@@ -53,8 +53,6 @@ export async function handleFinalize(projectResolver, cwd, raw) {
|
|
|
53
53
|
finally {
|
|
54
54
|
conn.close();
|
|
55
55
|
}
|
|
56
|
-
if (openTasks.length > 0)
|
|
57
|
-
return err("wp_blueprint_finalize refused", `Blueprint "${slug}" has open tasks: ${openTasks.map((t) => `${t.task_id} (${t.status})`).join(", ")}`);
|
|
58
56
|
const root = resolveBlueprintRoot(projectCwd);
|
|
59
57
|
const found = findBlueprintDir(root, slug, NON_COMPLETED_BLUEPRINT_STATES);
|
|
60
58
|
if (!found) {
|
|
@@ -69,11 +67,24 @@ export async function handleFinalize(projectResolver, cwd, raw) {
|
|
|
69
67
|
});
|
|
70
68
|
return err("wp_blueprint_finalize failed", `Blueprint "${slug}" not found`);
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
// Gather EVERY blocking condition up front, from every check this handler
|
|
71
|
+
// (and applyLocalBlueprintTransition, invoked later) is capable of raising
|
|
72
|
+
// — open tasks per the DB projection, zero-task/unfinished-task/missing-
|
|
73
|
+
// evidence per the raw markdown, doc-validate gaps, and the completed
|
|
74
|
+
// approval/digest gate. Refusing once per condition costs one failed
|
|
75
|
+
// round-trip per fix; a single combined refusal costs one. This also
|
|
76
|
+
// fixes atomicity at the handler level: neither the platform-sync side
|
|
77
|
+
// effect below nor the local move in applyLocalBlueprintTransition is
|
|
78
|
+
// ever attempted while any blocker remains, so a failed finalize call
|
|
79
|
+
// can never partially move — or platform-sync without locally moving —
|
|
80
|
+
// the blueprint.
|
|
81
|
+
const blockers = [];
|
|
82
|
+
if (openTasks.length > 0) {
|
|
83
|
+
blockers.push(`Blueprint "${slug}" has open tasks: ${openTasks.map((t) => `${t.task_id} (${t.status})`).join(", ")}`);
|
|
74
84
|
}
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
blockers.push(...collectBlueprintCompletionBlockers(found.path, slug));
|
|
86
|
+
if (blockers.length > 0) {
|
|
87
|
+
return err("wp_blueprint_finalize refused", blockers.join("; "));
|
|
77
88
|
}
|
|
78
89
|
// Platform-first path: push event + pull fresh replica before local move.
|
|
79
90
|
// Iron rule: resolveSyncAdapter() returns null when WP_BLUEPRINT_PLATFORM_DISABLED=1.
|
|
@@ -255,7 +255,7 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
|
|
|
255
255
|
"next_action",
|
|
256
256
|
],
|
|
257
257
|
}, (r) => handleBlueprintCreate(projectResolver, cwd, r), { title: "Blueprint Create", destructiveHint: false, openWorldHint: false });
|
|
258
|
-
registrar.registerTool("wp_blueprint_task_verify", "Mark a task done with an Evidence Contract. Requires at least one pass evidence item. Accepts optional request_id for idempotent retries, optional head_at_ingest from wp_blueprint_get/wp_blueprint_context to reject stale writes, and re-ingests DB on success. Returns { status, idempotent, next_action? }.", {
|
|
258
|
+
registrar.registerTool("wp_blueprint_task_verify", "Mark a task done with an Evidence Contract. Requires at least one pass evidence item. Accepts optional request_id for idempotent retries, optional head_at_ingest from wp_blueprint_get/wp_blueprint_context to reject stale writes, and re-ingests DB on success. Returns { status, idempotent, next_action? }. Each evidence item's required fields depend on its `kind`: 'test' requires `command` + `exit_code` (must be 0 when result='pass'); 'integration' additionally requires a non-empty `target_files`; 'audit' requires `audit_kind` + `passed:true` when result='pass'; 'manual' requires `actor`, `description`, the literal `allow_manual:true` (an explicit anti-shortcut opt-in), and a non-empty `log_excerpt` (<=4096 bytes) — it does NOT accept a `note` field.", {
|
|
259
259
|
type: "object",
|
|
260
260
|
properties: {
|
|
261
261
|
project_id: { type: "string" },
|
|
@@ -271,7 +271,36 @@ export async function registerBlueprintTools(registrar, cwd, projectResolver = c
|
|
|
271
271
|
properties: {
|
|
272
272
|
kind: { type: "string", enum: ["test", "integration", "audit", "manual"] },
|
|
273
273
|
result: { type: "string", enum: ["pass", "fail"] },
|
|
274
|
-
ts: { type: "string" },
|
|
274
|
+
ts: { type: "string", description: "ISO 8601 date-time string" },
|
|
275
|
+
agent: { type: "string" },
|
|
276
|
+
command: {
|
|
277
|
+
type: "string",
|
|
278
|
+
description: "Required for kind='test'/'integration'",
|
|
279
|
+
},
|
|
280
|
+
exit_code: {
|
|
281
|
+
type: "number",
|
|
282
|
+
description: "Required for kind='test'/'integration'; must be 0 when result='pass'",
|
|
283
|
+
},
|
|
284
|
+
target_files: {
|
|
285
|
+
type: "array",
|
|
286
|
+
items: { type: "string" },
|
|
287
|
+
description: "Required non-empty array for kind='integration'",
|
|
288
|
+
},
|
|
289
|
+
audit_kind: { type: "string", description: "Required for kind='audit'" },
|
|
290
|
+
passed: {
|
|
291
|
+
type: "boolean",
|
|
292
|
+
description: "Required for kind='audit'; must be true when result='pass'",
|
|
293
|
+
},
|
|
294
|
+
actor: { type: "string", description: "Required for kind='manual'" },
|
|
295
|
+
description: { type: "string", description: "Required for kind='manual'" },
|
|
296
|
+
allow_manual: {
|
|
297
|
+
type: "boolean",
|
|
298
|
+
description: "Required for kind='manual'; must be the literal boolean true (anti-shortcut opt-in)",
|
|
299
|
+
},
|
|
300
|
+
log_excerpt: {
|
|
301
|
+
type: "string",
|
|
302
|
+
description: "Required for kind='manual'; <=4096 bytes",
|
|
303
|
+
},
|
|
275
304
|
},
|
|
276
305
|
required: ["kind", "result", "ts"],
|
|
277
306
|
},
|
|
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
|
|
|
9
9
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { evaluateReviewEvents, parseReviewEventLog } from "./events.js";
|
|
12
|
+
import { isPolicyApprovalVerdict } from "./verdict.js";
|
|
12
13
|
import { createDeliverySubjectAtRef, createLegacyPlanSubjectAtRef, createPlanSubjectAtRef, createPreTagsStripPlanSubjectAtRef, resolveReviewCommit, } from "./subject.js";
|
|
13
14
|
const GIT_TIMEOUT_MS = 5_000;
|
|
14
15
|
const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
@@ -114,6 +115,54 @@ function markCompromisedArtifactEvents(cwd, entries, blueprintDirectory, events)
|
|
|
114
115
|
}
|
|
115
116
|
return { compromisedIds, issues };
|
|
116
117
|
}
|
|
118
|
+
/** Bounded `git branch -a --contains <commit>` probe, tolerant of a pruned/unknown object. */
|
|
119
|
+
function branchesContainingCommit(cwd, commit) {
|
|
120
|
+
try {
|
|
121
|
+
const output = gitBuffer(cwd, ["branch", "-a", "--contains", commit]).toString("utf8");
|
|
122
|
+
return output
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((line) => line.trim())
|
|
125
|
+
.filter((line) => line.length > 0);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Diagnostic-only: names historical ledger approvals whose `reviewedCommit` is
|
|
132
|
+
// no longer reachable from any branch (typically because a rebase rewrote the
|
|
133
|
+
// history that commit lived on). Such an approval no longer matches the
|
|
134
|
+
// current subjectDigest, so it already contributes zero approvals under the
|
|
135
|
+
// existing policy in ./events.ts -- this only makes the reason visible instead
|
|
136
|
+
// of the operator seeing a bare "approvals does not satisfy the promotion
|
|
137
|
+
// reviewer mix" and mistaking a dead commit ref for a genuinely missing
|
|
138
|
+
// review. Never mutates `approvals`/`matching`; policy is unchanged.
|
|
139
|
+
function markOrphanedApprovalEvents(cwd, events, current) {
|
|
140
|
+
const issues = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
if (event.purpose !== current.purpose ||
|
|
144
|
+
event.blueprintSlug !== current.blueprintSlug ||
|
|
145
|
+
event.subjectScheme !== current.subjectScheme ||
|
|
146
|
+
event.subjectDigest === current.subjectDigest ||
|
|
147
|
+
event.status !== "complete" ||
|
|
148
|
+
!isPolicyApprovalVerdict(event.verdict)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const key = `${event.reviewer}:${event.reviewedCommit}`;
|
|
152
|
+
if (seen.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
const branches = branchesContainingCommit(cwd, event.reviewedCommit);
|
|
156
|
+
if (branches === null || branches.length === 0) {
|
|
157
|
+
issues.push(`Historical approval from ${event.reviewer} (event ${event.id}) reviewed commit ` +
|
|
158
|
+
`${event.reviewedCommit}, which is unreachable from any branch (git branch -a ` +
|
|
159
|
+
`--contains ${event.reviewedCommit} found nothing) -- likely orphaned by a rebase. ` +
|
|
160
|
+
"This approval does not count toward the current subject; rebase this worktree onto " +
|
|
161
|
+
"the latest base and re-run the gate.");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return issues;
|
|
165
|
+
}
|
|
117
166
|
export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
|
|
118
167
|
const commit = resolveReviewCommit(cwd, ref);
|
|
119
168
|
const entries = parseRefTree(cwd, commit);
|
|
@@ -131,6 +180,12 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
|
|
|
131
180
|
const subject = purpose === "plan"
|
|
132
181
|
? createPlanSubjectAtRef(cwd, commit, stableSlug, overview.path)
|
|
133
182
|
: createDeliverySubjectAtRef(cwd, commit, stableSlug, overview.path);
|
|
183
|
+
const orphanIssues = markOrphanedApprovalEvents(cwd, events, {
|
|
184
|
+
purpose,
|
|
185
|
+
blueprintSlug: stableSlug,
|
|
186
|
+
subjectScheme: subject.scheme,
|
|
187
|
+
subjectDigest: subject.digest,
|
|
188
|
+
});
|
|
134
189
|
let evaluation = evaluateReviewEvents(events, {
|
|
135
190
|
purpose,
|
|
136
191
|
blueprintSlug: stableSlug,
|
|
@@ -166,7 +221,7 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
|
|
|
166
221
|
subject,
|
|
167
222
|
events,
|
|
168
223
|
...evaluation,
|
|
169
|
-
issues,
|
|
224
|
+
issues: [...issues, ...orphanIssues],
|
|
170
225
|
};
|
|
171
226
|
}
|
|
172
227
|
export function readWorkingReviewEvents(blueprintPath, stableSlug) {
|
|
@@ -21,6 +21,10 @@ export interface ReviewGateVerification {
|
|
|
21
21
|
readonly logPath?: string;
|
|
22
22
|
readonly sandbox?: ReviewSandboxMode;
|
|
23
23
|
readonly sandboxReason?: string;
|
|
24
|
+
readonly violations?: readonly {
|
|
25
|
+
readonly section: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}[];
|
|
24
28
|
}
|
|
25
29
|
/** Runs one gate command in the review checkout and returns its raw result. */
|
|
26
30
|
export type RunSandboxedGate = (args: {
|
|
@@ -18,7 +18,15 @@ export async function verifyDeliveryPromotionGates(input) {
|
|
|
18
18
|
parsed.violations[0]?.message === "missing Trust Dossier section") {
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
status: "failed",
|
|
23
|
+
commands: [],
|
|
24
|
+
failureCode: "malformed-trust-dossier",
|
|
25
|
+
violations: parsed.violations.map((violation) => ({
|
|
26
|
+
section: violation.section,
|
|
27
|
+
message: violation.message,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
22
30
|
}
|
|
23
31
|
const gates = parsed.dossier?.gates ?? [];
|
|
24
32
|
if (gates.length === 0)
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the canonical `.agent/{rules,skills}` SSOT always project.
|
|
18
18
|
*
|
|
19
19
|
* The `UNIFIED_CONSUMERS` registry below describes per-IDE projection of the
|
|
20
|
-
* unified rule/skill content kinds (catalog ∪ consumer). Strategies:
|
|
20
|
+
* unified rule/skill/agent content kinds (catalog ∪ consumer). Strategies:
|
|
21
21
|
* - 'symlink': create a relative symlink to the source (file or dir)
|
|
22
22
|
* - 'copy': copy file or recursively copy dir tree
|
|
23
23
|
* - 'transform': run a transform function over the body and write the
|
|
@@ -78,6 +78,23 @@ export interface UnifiedConsumerConfig {
|
|
|
78
78
|
* always project.
|
|
79
79
|
*/
|
|
80
80
|
readonly host?: AgentHostName;
|
|
81
|
+
/**
|
|
82
|
+
* Who owns the target directory, and therefore what agent-kit may destroy in
|
|
83
|
+
* it. Defaults to `"exclusive"`, preserving the historical sweep behavior.
|
|
84
|
+
*
|
|
85
|
+
* - `"exclusive"` — agent-kit owns the directory. It is generated and
|
|
86
|
+
* gitignored, has no hand-authoring workflow, and unexpected entries
|
|
87
|
+
* matching the managed shape are stale projections, safe to remove.
|
|
88
|
+
* - `"shared"` — a host-native authoring surface co-owned with the user
|
|
89
|
+
* (`.claude/agents` is Claude Code's own subagent authoring location).
|
|
90
|
+
* agent-kit may destroy only paths it can prove it created; anything else
|
|
91
|
+
* is preserved, because the surface is gitignored and loss is permanent.
|
|
92
|
+
*
|
|
93
|
+
* Declared rather than inferred on purpose: the stance used to be derived
|
|
94
|
+
* from `acceptsKind === "agent"` at each use site, which meant one code path
|
|
95
|
+
* had the guard and another silently did not.
|
|
96
|
+
*/
|
|
97
|
+
readonly ownership?: "exclusive" | "shared";
|
|
81
98
|
/**
|
|
82
99
|
* Legacy flag retained for shape compatibility. Plugin hosts no longer
|
|
83
100
|
* reactivate project-local skill projections under skip envs.
|
|
@@ -116,6 +133,8 @@ export declare function unifiedRuleFilename(consumer: UnifiedConsumerConfig, slu
|
|
|
116
133
|
* `selectUnifiedConsumers(hosts)`:
|
|
117
134
|
* - `.agent/{rules,skills}/` (working dir, SSOT): symlink, always
|
|
118
135
|
* - `.claude/rules/`: symlink, always (rules are not plugin-delivered)
|
|
136
|
+
* - `.claude/agents/`: symlink, always (canonical subagents; Claude has no
|
|
137
|
+
* native subagent delivery channel, so this projection is not plugin-owned)
|
|
119
138
|
*
|
|
120
139
|
* Project-local legacy skill roots are intentionally omitted. Codex has no
|
|
121
140
|
* `.codex/agents/` consumer.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the canonical `.agent/{rules,skills}` SSOT always project.
|
|
18
18
|
*
|
|
19
19
|
* The `UNIFIED_CONSUMERS` registry below describes per-IDE projection of the
|
|
20
|
-
* unified rule/skill content kinds (catalog ∪ consumer). Strategies:
|
|
20
|
+
* unified rule/skill/agent content kinds (catalog ∪ consumer). Strategies:
|
|
21
21
|
* - 'symlink': create a relative symlink to the source (file or dir)
|
|
22
22
|
* - 'copy': copy file or recursively copy dir tree
|
|
23
23
|
* - 'transform': run a transform function over the body and write the
|
|
@@ -59,6 +59,8 @@ export function unifiedRuleFilename(consumer, slug) {
|
|
|
59
59
|
* `selectUnifiedConsumers(hosts)`:
|
|
60
60
|
* - `.agent/{rules,skills}/` (working dir, SSOT): symlink, always
|
|
61
61
|
* - `.claude/rules/`: symlink, always (rules are not plugin-delivered)
|
|
62
|
+
* - `.claude/agents/`: symlink, always (canonical subagents; Claude has no
|
|
63
|
+
* native subagent delivery channel, so this projection is not plugin-owned)
|
|
62
64
|
*
|
|
63
65
|
* Project-local legacy skill roots are intentionally omitted. Codex has no
|
|
64
66
|
* `.codex/agents/` consumer.
|
|
@@ -69,6 +71,18 @@ export const DEFAULT_UNIFIED_CONSUMERS = [
|
|
|
69
71
|
{ id: "agent-skills", dir: ".agent/skills", acceptsKind: "skill", strategy: "symlink" },
|
|
70
72
|
// Claude: rules are scaffolded to .claude/rules; skills come from native channels.
|
|
71
73
|
{ id: "claude-rules", dir: ".claude/rules", acceptsKind: "rule", strategy: "symlink" },
|
|
74
|
+
// Claude: canonical subagents are scaffolded to .claude/agents. Host-agnostic
|
|
75
|
+
// (unconditional) because subagents were previously scaffolded unconditionally
|
|
76
|
+
// by the now-retired standalone scaffoldSubagents scaffolder.
|
|
77
|
+
{
|
|
78
|
+
id: "claude-agents",
|
|
79
|
+
dir: ".claude/agents",
|
|
80
|
+
acceptsKind: "agent",
|
|
81
|
+
strategy: "symlink",
|
|
82
|
+
// Claude Code's own subagent authoring location: the user may hand-author
|
|
83
|
+
// here, and agent-kit gitignores it, so a wrong deletion is unrecoverable.
|
|
84
|
+
ownership: "shared",
|
|
85
|
+
},
|
|
72
86
|
];
|
|
73
87
|
/**
|
|
74
88
|
* Projection surfaces retired by a newer agent-kit release.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may destroy a path under a consumer directory.
|
|
3
|
+
*
|
|
4
|
+
* This is the single owner of that question. It exists because the answer was
|
|
5
|
+
* previously implemented twice — conservatively in `pruneStale`, not at all in
|
|
6
|
+
* `applyTarget` — and the two drifted, which is how a consumer-authored file
|
|
7
|
+
* whose name collided with a catalog slug came to be deleted silently.
|
|
8
|
+
*
|
|
9
|
+
* The predicate is deliberately narrow: agent-kit may destroy a path only when
|
|
10
|
+
* it can PROVE it created that path, i.e. the path is a symlink resolving into
|
|
11
|
+
* a directory agent-kit projects from. A real file, a real directory, or a
|
|
12
|
+
* symlink pointing anywhere else is somebody else's and is never destroyed on a
|
|
13
|
+
* `shared` surface.
|
|
14
|
+
*/
|
|
15
|
+
export interface ManagedSourceDirsInput {
|
|
16
|
+
/** Catalog root passed to `runUnifiedSync` (may itself be a symlink alias). */
|
|
17
|
+
readonly catalogDir: string;
|
|
18
|
+
/** Consumer repo root, already realpath-resolved by the caller. */
|
|
19
|
+
readonly consumerRoot: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Directories agent-kit projects agent records from.
|
|
23
|
+
*
|
|
24
|
+
* Derived from CONFIGURATION, never from the planned record set, so the result
|
|
25
|
+
* is non-empty even when the catalog agents directory is empty or entirely
|
|
26
|
+
* absent. That matters: with a plans-derived set, a broken or partial install
|
|
27
|
+
* produces zero plans, an empty managed set, and a sweep that can no longer
|
|
28
|
+
* tell agent-kit's own relics from the operator's files.
|
|
29
|
+
*
|
|
30
|
+
* The catalog child is derived from `realpathSync(catalogDir)` rather than by
|
|
31
|
+
* realpathing the child itself. `loadContent` throws when `catalogDir` is
|
|
32
|
+
* missing, so the parent always resolves — whereas the child may not exist yet
|
|
33
|
+
* (or any more), and a non-existent child silently contributes only its
|
|
34
|
+
* unresolved alias form, which then fails to match a symlink recorded against
|
|
35
|
+
* the real path.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveManagedSourceDirs(input: ManagedSourceDirsInput): ReadonlySet<string>;
|
|
38
|
+
/**
|
|
39
|
+
* True when `targetPath` is a symlink agent-kit projected — the only shape it
|
|
40
|
+
* is allowed to destroy on a `shared` surface.
|
|
41
|
+
*
|
|
42
|
+
* The resolved target need not still exist: a dangling link left behind by a
|
|
43
|
+
* removed catalog record is agent-kit's own relic and must stay prunable.
|
|
44
|
+
* Containment is delegated to `isPathContained`, which canonicalizes both sides
|
|
45
|
+
* and rejects `..` escapes, so a sibling directory such as `<managed>-evil`
|
|
46
|
+
* cannot masquerade as managed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isAgentKitOwned(targetPath: string, managedSourceDirs: ReadonlySet<string>): boolean;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may destroy a path under a consumer directory.
|
|
3
|
+
*
|
|
4
|
+
* This is the single owner of that question. It exists because the answer was
|
|
5
|
+
* previously implemented twice — conservatively in `pruneStale`, not at all in
|
|
6
|
+
* `applyTarget` — and the two drifted, which is how a consumer-authored file
|
|
7
|
+
* whose name collided with a catalog slug came to be deleted silently.
|
|
8
|
+
*
|
|
9
|
+
* The predicate is deliberately narrow: agent-kit may destroy a path only when
|
|
10
|
+
* it can PROVE it created that path, i.e. the path is a symlink resolving into
|
|
11
|
+
* a directory agent-kit projects from. A real file, a real directory, or a
|
|
12
|
+
* symlink pointing anywhere else is somebody else's and is never destroyed on a
|
|
13
|
+
* `shared` surface.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { isPathContained } from "#worktrees/identity.js";
|
|
18
|
+
/**
|
|
19
|
+
* Directories agent-kit projects agent records from.
|
|
20
|
+
*
|
|
21
|
+
* Derived from CONFIGURATION, never from the planned record set, so the result
|
|
22
|
+
* is non-empty even when the catalog agents directory is empty or entirely
|
|
23
|
+
* absent. That matters: with a plans-derived set, a broken or partial install
|
|
24
|
+
* produces zero plans, an empty managed set, and a sweep that can no longer
|
|
25
|
+
* tell agent-kit's own relics from the operator's files.
|
|
26
|
+
*
|
|
27
|
+
* The catalog child is derived from `realpathSync(catalogDir)` rather than by
|
|
28
|
+
* realpathing the child itself. `loadContent` throws when `catalogDir` is
|
|
29
|
+
* missing, so the parent always resolves — whereas the child may not exist yet
|
|
30
|
+
* (or any more), and a non-existent child silently contributes only its
|
|
31
|
+
* unresolved alias form, which then fails to match a symlink recorded against
|
|
32
|
+
* the real path.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveManagedSourceDirs(input) {
|
|
35
|
+
const dirs = new Set();
|
|
36
|
+
const catalogRoots = new Set([resolve(input.catalogDir)]);
|
|
37
|
+
if (existsSync(input.catalogDir))
|
|
38
|
+
catalogRoots.add(realpathSync(input.catalogDir));
|
|
39
|
+
for (const catalogRoot of catalogRoots)
|
|
40
|
+
dirs.add(join(catalogRoot, "agents"));
|
|
41
|
+
// Consumer-side override dir. It is frequently absent (no scaffolder creates
|
|
42
|
+
// it), so it gets the same parent-anchored treatment.
|
|
43
|
+
const consumerRoots = new Set([resolve(input.consumerRoot)]);
|
|
44
|
+
if (existsSync(input.consumerRoot))
|
|
45
|
+
consumerRoots.add(realpathSync(input.consumerRoot));
|
|
46
|
+
for (const consumerRootForm of consumerRoots)
|
|
47
|
+
dirs.add(join(consumerRootForm, "agent-agents"));
|
|
48
|
+
return dirs;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True when `targetPath` is a symlink agent-kit projected — the only shape it
|
|
52
|
+
* is allowed to destroy on a `shared` surface.
|
|
53
|
+
*
|
|
54
|
+
* The resolved target need not still exist: a dangling link left behind by a
|
|
55
|
+
* removed catalog record is agent-kit's own relic and must stay prunable.
|
|
56
|
+
* Containment is delegated to `isPathContained`, which canonicalizes both sides
|
|
57
|
+
* and rejects `..` escapes, so a sibling directory such as `<managed>-evil`
|
|
58
|
+
* cannot masquerade as managed.
|
|
59
|
+
*/
|
|
60
|
+
export function isAgentKitOwned(targetPath, managedSourceDirs) {
|
|
61
|
+
let stat;
|
|
62
|
+
try {
|
|
63
|
+
stat = lstatSync(targetPath);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isSymbolicLink())
|
|
69
|
+
return false;
|
|
70
|
+
let resolved;
|
|
71
|
+
try {
|
|
72
|
+
resolved = resolve(dirname(targetPath), readlinkSync(targetPath));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const linkParent = dirname(resolved);
|
|
78
|
+
for (const managedDir of managedSourceDirs) {
|
|
79
|
+
if (isPathContained(managedDir, linkParent))
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified rule + skill sync.
|
|
2
|
+
* Unified rule + skill + agent sync.
|
|
3
3
|
*
|
|
4
|
-
* Source of truth: catalog (`<pkg>/dist/catalog/agent/{rules,skills}/`)
|
|
5
|
-
* consumer (`<repo>/agent-{rules,skills}/`). The loader returns a
|
|
4
|
+
* Source of truth: catalog (`<pkg>/dist/catalog/agent/{rules,skills,agents}/`)
|
|
5
|
+
* UNION consumer (`<repo>/agent-{rules,skills,agents}/`). The loader returns a
|
|
6
6
|
* source-tagged record list; this module projects that list into per-IDE
|
|
7
7
|
* surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
|
|
8
8
|
*
|
|
@@ -16,6 +16,22 @@
|
|
|
16
16
|
* filename pattern but is not in the expected set is removed. This propagates
|
|
17
17
|
* deletions in `agent-rules/` and `agent-skills/` to per-IDE cleanup.
|
|
18
18
|
*
|
|
19
|
+
* Ownership: a consumer declares `ownership` (see `UnifiedConsumerConfig`).
|
|
20
|
+
* On a `shared` surface agent-kit destroys only what it can prove it created,
|
|
21
|
+
* via the single predicate in `ownership.ts`. Both write paths consult that one
|
|
22
|
+
* predicate but take DIFFERENT actions, and the asymmetry is deliberate:
|
|
23
|
+
*
|
|
24
|
+
* | path | situation | action |
|
|
25
|
+
* |-------------|------------------------------------|-------------------------|
|
|
26
|
+
* | pruneStale | nothing in the catalog claims this | leave untouched |
|
|
27
|
+
* | | name | |
|
|
28
|
+
* | applyTarget | a catalog record DOES claim it | preserve + skip + warn |
|
|
29
|
+
*
|
|
30
|
+
* Quarantining or deleting an unclaimed file would be gratuitous — it is in
|
|
31
|
+
* nobody's way. Silently replacing a claimed one is the data-loss bug this
|
|
32
|
+
* guard exists to prevent, because `.claude/agents` is gitignored and the loss
|
|
33
|
+
* is unrecoverable.
|
|
34
|
+
*
|
|
19
35
|
* `--check` mode (dry-run): produce a list of (target, status) pairs and
|
|
20
36
|
* return the count of mismatches; perform no writes.
|
|
21
37
|
*/
|
|
@@ -68,6 +84,22 @@ export interface UnifiedSyncResult {
|
|
|
68
84
|
readonly fixCount: number;
|
|
69
85
|
/** Mismatches surfaced in check mode. Empty in non-check mode. */
|
|
70
86
|
readonly mismatches: readonly UnifiedSyncMismatch[];
|
|
87
|
+
/**
|
|
88
|
+
* Paths on a `shared` surface that agent-kit refused to replace because it
|
|
89
|
+
* could not prove it created them.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately NOT mismatches: a mismatch means drift the tool should fix,
|
|
92
|
+
* and `wp sync --check` exits non-zero on drift. A user who legitimately
|
|
93
|
+
* customized a subagent would then red every CI run forever, which is the
|
|
94
|
+
* pressure that makes people delete their own file. These are warnings.
|
|
95
|
+
*/
|
|
96
|
+
readonly preserved: readonly UnifiedSyncPreserved[];
|
|
97
|
+
}
|
|
98
|
+
export interface UnifiedSyncPreserved {
|
|
99
|
+
readonly consumerId: string;
|
|
100
|
+
readonly targetPath: string;
|
|
101
|
+
/** Slug the catalog wanted to project there. */
|
|
102
|
+
readonly slug: string;
|
|
71
103
|
}
|
|
72
104
|
export declare function isSymlinkPointingTo(linkPath: string, expectedAbs: string): boolean;
|
|
73
105
|
/**
|