@yeaft/webchat-agent 1.0.495 → 1.0.497
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/bin/yeaft-repo.js +4 -0
- package/local-runtime/version.json +1 -1
- package/package.json +3 -1
- package/repo-workflow-cli.js +101 -0
- package/repo-workflow.js +1437 -0
- package/skills/review-merge-tag/SKILL.md +78 -0
- package/yeaft/cli-session-runner.js +4 -1
- package/yeaft/engine.js +2 -0
- package/yeaft/init.js +5 -2
- package/yeaft/routing/router.js +140 -2
- package/yeaft/skills.js +58 -37
- package/yeaft/sub-agent/execution-control.js +90 -0
- package/yeaft/sub-agent/liveness.js +7 -0
- package/yeaft/sub-agent/runner.js +65 -13
- package/yeaft/sub-agent/spawned-prompt.js +20 -4
- package/yeaft/tools/activation.js +3 -0
- package/yeaft/tools/agent.js +27 -16
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/repo-workflow.js +80 -0
- package/yeaft/tools/route-forward.js +19 -1
- package/yeaft/web-bridge.js +65 -7
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review-merge-tag
|
|
3
|
+
description: 用 RepoWorkflow 在任意 GitHub 仓库执行开发 worktree、精确 PR review snapshot、base-ref CAS landing、数字 tag 与 workflow 验证;避免重复手工 git/gh 检查。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Repository Workflow
|
|
7
|
+
|
|
8
|
+
不要逐条调用 `git fetch`、`git worktree list`、`gh pr view`、`git ls-remote` 和 `gh run watch` 来发现同一批事实。Session 内使用 `RepoWorkflow` 工具。非 Yeaft 环境可用 `yeaft-repo` CLI 执行 `prepare` 和 `review-prep`,这两个阶段返回与工具相同的紧凑 JSON;CLI 不能执行 `land`。
|
|
9
|
+
|
|
10
|
+
## 三个边界
|
|
11
|
+
|
|
12
|
+
### 1. 开发准备
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"phase": "prepare",
|
|
17
|
+
"name": "fix-short-description"
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
它会在当前仓库中确定远端默认分支、拉取最新精确基线、检查 worktree/branch/path 冲突,并创建或安全复用 `yeaft-wt/<name>`。后续开发只使用返回的 `worktree.path`。
|
|
22
|
+
|
|
23
|
+
### 2. 独立 review 准备
|
|
24
|
+
|
|
25
|
+
Reviewer 调用:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"phase": "review-prep",
|
|
30
|
+
"pr": 123
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
它会一次冻结 GitHub PR 的 base branch/base SHA/head/merge snapshot、检查 draft/conflict/checks,并创建 detached exact-snapshot review worktree。Reviewer 必须审查返回的 `reviewWorktree.path`,并把明确结论、`pullRequest.baseBranch`、`pullRequest.baseSha`、`pullRequest.headSha` 和 `pullRequest.snapshotSha` 交回开发者。
|
|
35
|
+
|
|
36
|
+
工具只冻结事实,不替代代码审查。
|
|
37
|
+
|
|
38
|
+
### 3. Review 通过后的落地
|
|
39
|
+
|
|
40
|
+
只有收到独立 reviewer 的明确批准后才能调用。Reviewer 必须用 `RouteForward` 把绑定仓库、PR、exact base branch/base SHA/head/merge snapshot 的结构化批准交回 landing VP;宿主会随该 handoff 铸造不可序列化、一次性的 approval capability。随后 landing VP 才能在处理该 handoff 的同一 turn 调用:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"phase": "land",
|
|
45
|
+
"pr": 123,
|
|
46
|
+
"baseBranch": "main",
|
|
47
|
+
"baseSha": "<exact 40-char base SHA>",
|
|
48
|
+
"reviewedHead": "<exact 40-char head SHA>",
|
|
49
|
+
"reviewedSnapshot": "<exact 40-char merge snapshot SHA>",
|
|
50
|
+
"tagPrefix": "v1.0.",
|
|
51
|
+
"workflow": "Dev Release"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`approvedBy` 等普通字符串没有授权语义。`land` 只接受宿主 capability,并重新冻结 PR;任何 repo、PR、recipient、base branch、base SHA、head 或 snapshot 不匹配都会停止。Reviewed snapshot 必须以批准的 base SHA/head 为两个父提交;landing 的目标 ref 只从批准的 base branch 推导,并在任何写入前拒绝 PR retarget。之后它通过单次 receive-pack 更新,用 `--force-with-lease=<base-ref>:<approved-base>` 将该 exact snapshot 安装到批准的远端 base。若 base 在最后冻结与 receive 之间前进,lease 会拒绝更新,并且不会创建 tag。若要求 tag,它只在 base CAS 成功后从远端 tags 数值计算下一版,用 create-only lease 推送并复核 base 与 tag。若要求 workflow,它等待匹配 tag 与 reviewed snapshot SHA 的 run 完成。
|
|
56
|
+
|
|
57
|
+
Worktree cleanup 不属于 landing。`land` 会在任何仓库或远端副作用前拒绝 cleanup 请求,包括 active `repoRoot`;需要 cleanup 时必须在 landing 完成后走独立操作,cleanup 失败不能改变 landing 结果。
|
|
58
|
+
|
|
59
|
+
## 通用性与边界
|
|
60
|
+
|
|
61
|
+
- 适用于 Agent 可访问的任意本地 GitHub 仓库,不包含项目路径或固定 `main` 假设。
|
|
62
|
+
- 需要 `git` 和已认证的 `gh` CLI;不依赖 Python,也不通过 shell 拼接命令。
|
|
63
|
+
- 当前不支持 GitLab/Bitbucket;不要把 GitHub 成功误报为 forge-agnostic。
|
|
64
|
+
- 自动化只消除重复事实核对,不消除独立 review、测试选择、代码判断或用户授权。
|
|
65
|
+
- `land` 后若 workflow 失败,merge/tag 可能已经完成;读取错误 JSON 的 `details.remoteEffects`,不要盲目重试或重打 tag。
|
|
66
|
+
|
|
67
|
+
## CLI
|
|
68
|
+
|
|
69
|
+
Yeaft 外部或人工终端只可运行准备阶段:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
yeaft-repo prepare --name fix-short-description
|
|
73
|
+
yeaft-repo review-prep --pr 123
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
独立 CLI 不能执行 `land`。Landing 必须在 Session 中通过 `RepoWorkflow` 工具执行,并携带当前 handoff 由宿主签发、绑定 exact review 的 approval capability;普通参数或 `approvedBy` 字符串不能替代该 capability。
|
|
77
|
+
|
|
78
|
+
每条可用命令 stdout 只输出一个成功 JSON;失败时 stderr 输出一个带稳定 `code` 和副作用状态的 JSON。
|
|
@@ -250,7 +250,10 @@ export function createCliSessionRunner({
|
|
|
250
250
|
sessionMembers: meta.roster.slice(),
|
|
251
251
|
sessionAnnouncement: meta.announcement || '',
|
|
252
252
|
vpPersona: personaFactory(vpId, loaded),
|
|
253
|
-
router: createRouter({
|
|
253
|
+
router: createRouter({
|
|
254
|
+
coordinator: scopedCoordinator,
|
|
255
|
+
guard: routeForwardGuard,
|
|
256
|
+
}),
|
|
254
257
|
inboundEnvelope: envelope,
|
|
255
258
|
userAlreadyPersisted: true,
|
|
256
259
|
causalRootId,
|
package/yeaft/engine.js
CHANGED
|
@@ -1416,6 +1416,7 @@ export class Engine {
|
|
|
1416
1416
|
? vpCtx.projectSessionIds.slice()
|
|
1417
1417
|
: [],
|
|
1418
1418
|
threadId: vpCtx?.threadId || this.#currentThreadId || MAIN_THREAD_ID,
|
|
1419
|
+
turnId: vpCtx?.turnId || null,
|
|
1419
1420
|
currentVpId: vpCtx?.senderVpId || this.#vpId || null,
|
|
1420
1421
|
// task-704b: per-tool-result hard cap derives from this. Threaded
|
|
1421
1422
|
// from the live model (resolveModel(currentModel)) every turn so
|
|
@@ -3847,6 +3848,7 @@ export class Engine {
|
|
|
3847
3848
|
projectInstruction,
|
|
3848
3849
|
projectLabel,
|
|
3849
3850
|
threadId: runtimeThreadId,
|
|
3851
|
+
turnId: vpTurnId,
|
|
3850
3852
|
inboundEnvelope,
|
|
3851
3853
|
taskId,
|
|
3852
3854
|
taskMembers,
|
package/yeaft/init.js
CHANGED
|
@@ -15,7 +15,7 @@ import { mutateAgentConfig } from './config-store.js';
|
|
|
15
15
|
// layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
|
|
16
16
|
// Idempotent via the `.yeaft-migration.done` sentinel file.
|
|
17
17
|
import { migrateSessions } from './migrate/sessions.js';
|
|
18
|
-
import {
|
|
18
|
+
import { bundledYeaftSkillsDirs } from './skills.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Check if an error is a permission error (EACCES or EPERM).
|
|
@@ -353,7 +353,10 @@ function readManifest(filePath) {
|
|
|
353
353
|
* @returns {{ copied: number, updated: number, preserved: number, skipped: number }}
|
|
354
354
|
*/
|
|
355
355
|
export function seedBundledSkills(userSkillsDir, warnings = []) {
|
|
356
|
-
|
|
356
|
+
// Seed only the highest-priority root. Runtime loading still scans every
|
|
357
|
+
// bundled root; flattening lower-priority roots into the user tier here
|
|
358
|
+
// would erase bundled provenance and conflict ordering.
|
|
359
|
+
const bundled = bundledYeaftSkillsDirs().at(-1);
|
|
357
360
|
if (!bundled) {
|
|
358
361
|
return { copied: 0, updated: 0, preserved: 0, skipped: 0 };
|
|
359
362
|
}
|
package/yeaft/routing/router.js
CHANGED
|
@@ -28,6 +28,122 @@
|
|
|
28
28
|
import { resolveMemberId } from '../sessions/roster.js';
|
|
29
29
|
import { createLoopGuard, extendCausedBy } from './loop-guard.js';
|
|
30
30
|
|
|
31
|
+
const repoApprovals = new WeakMap();
|
|
32
|
+
const REPO_APPROVAL_ISSUER_IDS = new Set(['martin']);
|
|
33
|
+
export const REPO_APPROVAL_TTL_MS = 2 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
function normalizeId(value) {
|
|
36
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSha(value) {
|
|
40
|
+
const sha = normalizeId(value).toLowerCase();
|
|
41
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeApprovalRepository(value) {
|
|
45
|
+
const repository = normalizeId(value).replace(/\.git$/i, '');
|
|
46
|
+
const segments = repository.split('/');
|
|
47
|
+
if (segments.length === 2) segments.unshift('github.com');
|
|
48
|
+
if (segments.length !== 3) return '';
|
|
49
|
+
const [host, owner, name] = segments;
|
|
50
|
+
const safePart = part => /^[a-z0-9_.-]+$/i.test(part) && part !== '.' && part !== '..';
|
|
51
|
+
if (!safePart(host) || !safePart(owner) || !safePart(name) || !host.includes('.')) return '';
|
|
52
|
+
return `${host}/${owner}/${name}`.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRepoApprovalIssuer(vpId) {
|
|
56
|
+
return REPO_APPROVAL_ISSUER_IDS.has(normalizeId(vpId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mint authority only inside the canonical Router path after roster identity
|
|
61
|
+
* resolution. Keeping this function module-private prevents callers from
|
|
62
|
+
* turning a claimed issuer string into landing authority.
|
|
63
|
+
*/
|
|
64
|
+
function issueRepoApprovalCapability(input = {}, { now = Date.now } = {}) {
|
|
65
|
+
const sessionId = normalizeId(input.sessionId);
|
|
66
|
+
const issuerVpId = normalizeId(input.issuerVpId);
|
|
67
|
+
const recipientVpId = normalizeId(input.recipientVpId);
|
|
68
|
+
const repository = normalizeApprovalRepository(input.repository);
|
|
69
|
+
const pr = Number(input.pr);
|
|
70
|
+
const baseBranch = normalizeId(input.baseBranch);
|
|
71
|
+
const baseSha = normalizeSha(input.baseSha);
|
|
72
|
+
const reviewedHead = normalizeSha(input.reviewedHead);
|
|
73
|
+
const reviewedSnapshot = normalizeSha(input.reviewedSnapshot);
|
|
74
|
+
const issuedAt = Number(now());
|
|
75
|
+
if (!sessionId || !isRepoApprovalIssuer(issuerVpId) || !recipientVpId || issuerVpId === recipientVpId
|
|
76
|
+
|| !repository || !Number.isSafeInteger(pr) || pr <= 0 || !baseBranch || !baseSha
|
|
77
|
+
|| !reviewedHead || !reviewedSnapshot || !Number.isFinite(issuedAt)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const capability = Object.freeze(Object.create(null));
|
|
81
|
+
repoApprovals.set(capability, {
|
|
82
|
+
sessionId,
|
|
83
|
+
issuerVpId,
|
|
84
|
+
recipientVpId,
|
|
85
|
+
repository,
|
|
86
|
+
pr,
|
|
87
|
+
baseBranch,
|
|
88
|
+
baseSha,
|
|
89
|
+
reviewedHead,
|
|
90
|
+
reviewedSnapshot,
|
|
91
|
+
issuedAt,
|
|
92
|
+
expiresAt: issuedAt + REPO_APPROVAL_TTL_MS,
|
|
93
|
+
turnId: null,
|
|
94
|
+
});
|
|
95
|
+
return capability;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isRepoApprovalCapability(capability) {
|
|
99
|
+
return Boolean(capability && typeof capability === 'object' && repoApprovals.has(capability));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bind a freshly issued capability to the exact Web execution that received it. */
|
|
103
|
+
export function bindRepoApprovalCapability(capability, expected = {}, { now = Date.now } = {}) {
|
|
104
|
+
if (!capability || typeof capability !== 'object') return false;
|
|
105
|
+
const grant = repoApprovals.get(capability);
|
|
106
|
+
const turnId = normalizeId(expected.turnId);
|
|
107
|
+
const currentTime = Number(now());
|
|
108
|
+
if (!grant || !turnId || !Number.isFinite(currentTime)) return false;
|
|
109
|
+
if (currentTime > grant.expiresAt
|
|
110
|
+
|| grant.turnId
|
|
111
|
+
|| grant.sessionId !== normalizeId(expected.sessionId)
|
|
112
|
+
|| grant.recipientVpId !== normalizeId(expected.recipientVpId)) {
|
|
113
|
+
repoApprovals.delete(capability);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
grant.turnId = turnId;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function revokeRepoApprovalCapability(capability) {
|
|
121
|
+
if (!capability || typeof capability !== 'object') return false;
|
|
122
|
+
return repoApprovals.delete(capability);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Consume a capability exactly once and verify its complete landing tuple. */
|
|
126
|
+
export function consumeRepoApprovalCapability(capability, expected = {}, { now = Date.now } = {}) {
|
|
127
|
+
if (!capability || typeof capability !== 'object') return null;
|
|
128
|
+
const grant = repoApprovals.get(capability);
|
|
129
|
+
repoApprovals.delete(capability);
|
|
130
|
+
if (!grant) return null;
|
|
131
|
+
const currentTime = Number(now());
|
|
132
|
+
const matches = Number.isFinite(currentTime)
|
|
133
|
+
&& currentTime <= grant.expiresAt
|
|
134
|
+
&& grant.turnId
|
|
135
|
+
&& grant.turnId === normalizeId(expected.turnId)
|
|
136
|
+
&& grant.sessionId === normalizeId(expected.sessionId)
|
|
137
|
+
&& grant.recipientVpId === normalizeId(expected.recipientVpId)
|
|
138
|
+
&& grant.repository === normalizeApprovalRepository(expected.repository)
|
|
139
|
+
&& grant.pr === Number(expected.pr)
|
|
140
|
+
&& grant.baseBranch === normalizeId(expected.baseBranch)
|
|
141
|
+
&& grant.baseSha === normalizeSha(expected.baseSha)
|
|
142
|
+
&& grant.reviewedHead === normalizeSha(expected.reviewedHead)
|
|
143
|
+
&& grant.reviewedSnapshot === normalizeSha(expected.reviewedSnapshot);
|
|
144
|
+
return matches ? Object.freeze({ ...grant }) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
31
147
|
function routeForwardParentFromEnvelope(envelope) {
|
|
32
148
|
const msg = envelope?.msg;
|
|
33
149
|
const meta = msg?.meta;
|
|
@@ -75,7 +191,8 @@ export function createRouter(deps = {}) {
|
|
|
75
191
|
if (!coordinator || typeof coordinator.ingest !== 'function') {
|
|
76
192
|
throw new Error('createRouter: coordinator (with ingest()) is required');
|
|
77
193
|
}
|
|
78
|
-
const
|
|
194
|
+
const now = typeof deps.now === 'function' ? deps.now : Date.now;
|
|
195
|
+
const guard = deps.guard || createLoopGuard({ now });
|
|
79
196
|
|
|
80
197
|
/**
|
|
81
198
|
* Forward a message from a VP to another VP (or @all). Routes through
|
|
@@ -122,6 +239,8 @@ export function createRouter(deps = {}) {
|
|
|
122
239
|
}
|
|
123
240
|
const meta = coordinator.group.getMeta();
|
|
124
241
|
if (!meta) return { ok: false, error: 'group_not_initialised' };
|
|
242
|
+
const senderVpId = resolveMemberId(meta, from);
|
|
243
|
+
if (!senderVpId) return { ok: false, error: 'sender_not_in_roster' };
|
|
125
244
|
const claimedVpIds = args.inboundEnvelope?._cliTurnContext?.claimedVpIds;
|
|
126
245
|
|
|
127
246
|
// Roster membership — `all` is reserved broadcast sentinel handled by
|
|
@@ -132,7 +251,7 @@ export function createRouter(deps = {}) {
|
|
|
132
251
|
if (targetVpId !== 'all' && !targetVpId) {
|
|
133
252
|
return { ok: false, error: 'target_not_in_roster' };
|
|
134
253
|
}
|
|
135
|
-
if (targetVpId ===
|
|
254
|
+
if (targetVpId === senderVpId) {
|
|
136
255
|
return { ok: false, error: 'self_forward_rejected' };
|
|
137
256
|
}
|
|
138
257
|
if (targetVpId !== 'all' && claimedVpIds?.has?.(targetVpId)) {
|
|
@@ -146,6 +265,24 @@ export function createRouter(deps = {}) {
|
|
|
146
265
|
// spec's intent ("depth of forwards already taken").
|
|
147
266
|
const chain = extendCausedBy(args.inboundEnvelope || null, null);
|
|
148
267
|
const routeForwardParent = routeForwardParentFromEnvelope(args.inboundEnvelope);
|
|
268
|
+
let repoApproval = null;
|
|
269
|
+
if (args.repoApproval !== undefined) {
|
|
270
|
+
if (targetVpId === 'all') {
|
|
271
|
+
return { ok: false, error: 'repo_approval_requires_single_target' };
|
|
272
|
+
}
|
|
273
|
+
if (!isRepoApprovalIssuer(senderVpId)) {
|
|
274
|
+
return { ok: false, error: 'repo_approval_issuer_forbidden' };
|
|
275
|
+
}
|
|
276
|
+
repoApproval = issueRepoApprovalCapability({
|
|
277
|
+
...args.repoApproval,
|
|
278
|
+
sessionId: meta.id,
|
|
279
|
+
issuerVpId: senderVpId,
|
|
280
|
+
recipientVpId: targetVpId,
|
|
281
|
+
}, { now });
|
|
282
|
+
if (!repoApproval) {
|
|
283
|
+
return { ok: false, error: 'invalid_repo_approval' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
149
286
|
|
|
150
287
|
// Loop guard: for broadcast, use 'all' as the target key so one VP
|
|
151
288
|
// spamming @all still gets throttled even if each cycle hits different
|
|
@@ -182,6 +319,7 @@ export function createRouter(deps = {}) {
|
|
|
182
319
|
// of UI replay and future visible history so it doesn't render as a
|
|
183
320
|
// second assistant/user block after the target VP answers.
|
|
184
321
|
internal: true,
|
|
322
|
+
...(repoApproval ? { _repoApproval: repoApproval } : {}),
|
|
185
323
|
meta: {
|
|
186
324
|
synthetic: true,
|
|
187
325
|
injectedBy: 'route_forward',
|
package/yeaft/skills.js
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
*
|
|
29
29
|
* The standard tier order set up by `createSkillManager` is:
|
|
30
30
|
*
|
|
31
|
-
* tier 1 (bundled):
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
31
|
+
* tier 1 (bundled): all available external yeaft-skills roots plus the Agent
|
|
32
|
+
* package's own skills. External roots load first; Agent built-ins
|
|
33
|
+
* win bundled-name conflicts. Read-only — `save()` and `remove()`
|
|
34
|
+
* never target this tier.
|
|
34
35
|
* tier 2 (user): <yeaftDir>/skills (e.g. ~/.yeaft/skills). User edits
|
|
35
36
|
* land here. `save()` writes here. `init.js` seeds it from tier 1
|
|
36
37
|
* on first boot so users start with the bundled Yeaft skills.
|
|
@@ -1197,8 +1198,9 @@ export class SkillManager {
|
|
|
1197
1198
|
* Create a SkillManager wired with the standard layered tier list and load it.
|
|
1198
1199
|
*
|
|
1199
1200
|
* Tier order (lowest → highest priority):
|
|
1200
|
-
* 1. bundled —
|
|
1201
|
-
* `
|
|
1201
|
+
* 1. bundled — every available bundled root returned by
|
|
1202
|
+
* `bundledYeaftSkillsDirs()`. External packages add skills, while the
|
|
1203
|
+
* Agent package loads last so its built-in skills win bundled conflicts.
|
|
1202
1204
|
* 2. user — `<yeaftDir>/skills` (e.g. ~/.yeaft/skills). User edits + saves.
|
|
1203
1205
|
* 3. project-claude — `<workDir>/.claude/skills` when a workDir is provided.
|
|
1204
1206
|
* Claude Code project assets, loaded so a project that integrates Claude
|
|
@@ -1218,7 +1220,7 @@ export class SkillManager {
|
|
|
1218
1220
|
* @returns {SkillManager}
|
|
1219
1221
|
*/
|
|
1220
1222
|
export function createSkillManager(yeaftDir, workDir, options = {}) {
|
|
1221
|
-
const
|
|
1223
|
+
const bundledDirs = bundledYeaftSkillsDirs();
|
|
1222
1224
|
const userDir = join(yeaftDir, 'skills');
|
|
1223
1225
|
const projectRoots = [...new Set(String(workDir || '')
|
|
1224
1226
|
.split(delimiter)
|
|
@@ -1228,9 +1230,9 @@ export function createSkillManager(yeaftDir, workDir, options = {}) {
|
|
|
1228
1230
|
const codexProjectDirs = projectRoots.map(root => join(root, '.agents', 'skills'));
|
|
1229
1231
|
const projectDirs = projectRoots.map(root => join(root, '.yeaft', 'skills'));
|
|
1230
1232
|
|
|
1231
|
-
const dirs = [
|
|
1233
|
+
const dirs = [...bundledDirs, userDir, ...claudeProjectDirs, ...codexProjectDirs, ...projectDirs];
|
|
1232
1234
|
const tierByDir = {};
|
|
1233
|
-
|
|
1235
|
+
for (const dir of bundledDirs) tierByDir[dir] = 'bundled';
|
|
1234
1236
|
tierByDir[userDir] = 'user';
|
|
1235
1237
|
for (const dir of claudeProjectDirs) tierByDir[dir] = 'project-claude';
|
|
1236
1238
|
for (const dir of codexProjectDirs) tierByDir[dir] = 'project-codex';
|
|
@@ -1256,56 +1258,75 @@ export function createSkillManager(yeaftDir, workDir, options = {}) {
|
|
|
1256
1258
|
// ─── Bundled-skills resolver ──────────────────────────────
|
|
1257
1259
|
|
|
1258
1260
|
/**
|
|
1259
|
-
* Locate
|
|
1261
|
+
* Locate every available bundled Skill root on disk.
|
|
1260
1262
|
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1263
|
+
* The returned array is ordered from lowest to highest priority, matching the
|
|
1264
|
+
* `SkillManager` layered-load contract:
|
|
1265
|
+
* 1. ~/.claude/plugins/yeaft-skills/skills/ — alternate plugin layout
|
|
1266
|
+
* 2. ~/.claude/skills/yeaft-skills/skills/ — standard Claude Code layout
|
|
1267
|
+
* 3. $YEAFT_SKILLS_BUNDLED_DIR — explicit additional root
|
|
1268
|
+
* 4. <agent-pkg>/skills/ — authoritative Agent built-ins
|
|
1267
1269
|
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1270
|
+
* All existing roots coexist. The Agent package is deliberately last so an
|
|
1271
|
+
* external package or env override cannot shadow safety-critical built-ins.
|
|
1272
|
+
* User and project tiers still load after these roots and retain their existing
|
|
1273
|
+
* higher-priority customization semantics.
|
|
1272
1274
|
*
|
|
1273
|
-
* @returns {string
|
|
1275
|
+
* @returns {string[]}
|
|
1274
1276
|
*/
|
|
1275
|
-
export function
|
|
1277
|
+
export function bundledYeaftSkillsDirs() {
|
|
1276
1278
|
const candidates = [];
|
|
1279
|
+
const home = homedir();
|
|
1280
|
+
if (home) {
|
|
1281
|
+
candidates.push(join(home, '.claude', 'plugins', 'yeaft-skills', 'skills'));
|
|
1282
|
+
candidates.push(join(home, '.claude', 'skills', 'yeaft-skills', 'skills'));
|
|
1283
|
+
}
|
|
1277
1284
|
|
|
1278
1285
|
const envOverride = process.env.YEAFT_SKILLS_BUNDLED_DIR;
|
|
1279
1286
|
if (envOverride && typeof envOverride === 'string' && envOverride.length > 0) {
|
|
1280
1287
|
candidates.push(envOverride);
|
|
1281
1288
|
}
|
|
1282
1289
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
candidates.push(join(home, '.claude', 'skills', 'yeaft-skills', 'skills'));
|
|
1286
|
-
candidates.push(join(home, '.claude', 'plugins', 'yeaft-skills', 'skills'));
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Bundled-with-agent fallback: <agent-pkg-root>/skills. The package root is
|
|
1290
|
-
// the directory containing agent/, so we walk up from this file.
|
|
1290
|
+
// Bundled-with-agent root: <agent-pkg>/skills. The package root is the
|
|
1291
|
+
// directory containing yeaft/, so walk up from this file.
|
|
1291
1292
|
try {
|
|
1292
1293
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1293
|
-
|
|
1294
|
-
const agentRoot = join(here, '..', '..');
|
|
1295
|
-
candidates.push(join(agentRoot, 'skills'));
|
|
1294
|
+
candidates.push(join(here, '..', 'skills'));
|
|
1296
1295
|
} catch {
|
|
1297
1296
|
// fileURLToPath can throw on exotic loaders — non-fatal.
|
|
1298
1297
|
}
|
|
1299
1298
|
|
|
1300
|
-
|
|
1299
|
+
const roots = [];
|
|
1300
|
+
for (const candidate of candidates) {
|
|
1301
|
+
if (!candidate || roots.includes(candidate)) continue;
|
|
1301
1302
|
try {
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1303
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
1304
|
+
roots.push(candidate);
|
|
1304
1305
|
}
|
|
1305
1306
|
} catch {
|
|
1306
|
-
//
|
|
1307
|
+
// Permission errors etc. make this candidate unavailable.
|
|
1307
1308
|
}
|
|
1308
1309
|
}
|
|
1310
|
+
return roots;
|
|
1311
|
+
}
|
|
1309
1312
|
|
|
1310
|
-
|
|
1313
|
+
/**
|
|
1314
|
+
* Back-compatible singular resolver with the original first-existing order.
|
|
1315
|
+
* New callers that load Skills must use `bundledYeaftSkillsDirs()` so all
|
|
1316
|
+
* roots coexist.
|
|
1317
|
+
*
|
|
1318
|
+
* @returns {string|null}
|
|
1319
|
+
*/
|
|
1320
|
+
export function bundledYeaftSkillsDir() {
|
|
1321
|
+
const roots = bundledYeaftSkillsDirs();
|
|
1322
|
+
const envOverride = process.env.YEAFT_SKILLS_BUNDLED_DIR;
|
|
1323
|
+
const home = homedir();
|
|
1324
|
+
const legacyOrder = [
|
|
1325
|
+
envOverride,
|
|
1326
|
+
home ? join(home, '.claude', 'skills', 'yeaft-skills', 'skills') : null,
|
|
1327
|
+
home ? join(home, '.claude', 'plugins', 'yeaft-skills', 'skills') : null,
|
|
1328
|
+
];
|
|
1329
|
+
return legacyOrder.find(candidate => candidate && roots.includes(candidate))
|
|
1330
|
+
|| roots.at(-1)
|
|
1331
|
+
|| null;
|
|
1311
1332
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Sub-agent-local execution policy. No Session config or parent registry is mutated. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { ToolRegistry, isToolErrorOutput } from '../tools/registry.js';
|
|
4
|
+
|
|
5
|
+
/** Defaults are safety ceilings, not targets; explicit positive limits override each field. */
|
|
6
|
+
export function resolveSubAgentBudget(budget, persona) {
|
|
7
|
+
return {
|
|
8
|
+
max_tool_calls: persona === 'implementer' ? 128 : 64,
|
|
9
|
+
wall_time_ms: 15 * 60 * 1000,
|
|
10
|
+
...budget,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createExecutionStats() {
|
|
15
|
+
return { toolCalls: 0, completedCalls: 0, failedCalls: 0, repeatedResults: 0, recentCalls: [], warning: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fingerprint(value) {
|
|
19
|
+
return createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural child-only fence, including aliases and MCP hot registration. The
|
|
24
|
+
* synchronous reservation precedes execute(), so parallel calls cannot overshoot.
|
|
25
|
+
* Parent Active Tool Set checks remain independent and must not be bypassed.
|
|
26
|
+
*/
|
|
27
|
+
export class SubAgentToolRegistry extends ToolRegistry {
|
|
28
|
+
constructor({ allows = () => true, agent = null, stopBudget = null } = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.allows = allows;
|
|
31
|
+
this.agent = agent;
|
|
32
|
+
this.stopBudget = stopBudget;
|
|
33
|
+
this.recentFingerprints = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
register(tool) {
|
|
37
|
+
if (this.allows(tool)) super.register(tool);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async execute(name, input, ctx = {}) {
|
|
42
|
+
const tool = this.get(name);
|
|
43
|
+
if (!tool) throw new Error(`Unknown or disallowed child tool: ${name}`);
|
|
44
|
+
const agent = this.agent;
|
|
45
|
+
if (!agent) return super.execute(name, input, ctx);
|
|
46
|
+
// Serial dispatch can resume after a tool_start yield; never start a write
|
|
47
|
+
// after cancellation, even when the underlying tool ignores AbortSignal.
|
|
48
|
+
const signal = agent.abortController?.signal;
|
|
49
|
+
if (signal?.aborted) throw new Error(String(signal.reason || 'Sub-agent aborted'));
|
|
50
|
+
const stats = agent.execution || (agent.execution = createExecutionStats());
|
|
51
|
+
const limit = agent.budget?.max_tool_calls;
|
|
52
|
+
if (limit !== undefined && stats.toolCalls >= limit) {
|
|
53
|
+
const reason = `max_tool_calls (${limit}) reached; return partial evidence to the parent before extending scope`;
|
|
54
|
+
this.stopBudget?.(reason);
|
|
55
|
+
throw new Error(reason);
|
|
56
|
+
}
|
|
57
|
+
stats.toolCalls += 1;
|
|
58
|
+
agent.usage ||= { tokens: 0, turns: 0, startedAt: Date.now() };
|
|
59
|
+
agent.usage.toolCalls = stats.toolCalls;
|
|
60
|
+
const entry = { name: tool.name, status: 'running' };
|
|
61
|
+
stats.recentCalls.push(entry);
|
|
62
|
+
if (stats.recentCalls.length > 8) stats.recentCalls.shift();
|
|
63
|
+
if (limit && stats.toolCalls >= Math.ceil(limit * 0.75)) {
|
|
64
|
+
stats.warning = 'Tool budget nearly exhausted. Collect the current evidence; do not restart the same investigation.';
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const output = await super.execute(name, input, ctx);
|
|
68
|
+
const failed = tool.errorOutput === 'json-error-envelope' && isToolErrorOutput(output);
|
|
69
|
+
entry.status = failed ? 'error' : 'completed';
|
|
70
|
+
stats.completedCalls += 1;
|
|
71
|
+
if (failed) stats.failedCalls += 1;
|
|
72
|
+
// Advisory only: repeated results are not proof of semantic non-progress.
|
|
73
|
+
// Read ranges, cursors and changed outputs have distinct fingerprints.
|
|
74
|
+
try {
|
|
75
|
+
if (tool.isReadOnly?.(input) === true) {
|
|
76
|
+
const key = fingerprint([tool.name, input, output]);
|
|
77
|
+
if (this.recentFingerprints.includes(key)) stats.repeatedResults += 1;
|
|
78
|
+
this.recentFingerprints.push(key);
|
|
79
|
+
if (this.recentFingerprints.length > 12) this.recentFingerprints.shift();
|
|
80
|
+
}
|
|
81
|
+
} catch { /* Advisory instrumentation must never turn a successful tool into a retry. */ }
|
|
82
|
+
return output;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
entry.status = 'error';
|
|
85
|
+
stats.completedCalls += 1;
|
|
86
|
+
stats.failedCalls += 1;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -131,6 +131,13 @@ export function diagnoseAgentLiveness(agent, opts = {}) {
|
|
|
131
131
|
&& msSinceActivity >= thresholdMs;
|
|
132
132
|
return {
|
|
133
133
|
...liveness,
|
|
134
|
+
execution: agent?.execution ? {
|
|
135
|
+
...agent.execution,
|
|
136
|
+
recentCalls: agent.execution.recentCalls.map(call => ({ ...call })),
|
|
137
|
+
remainingToolCalls: Math.max(0, (agent.budget?.max_tool_calls || 0) - agent.execution.toolCalls),
|
|
138
|
+
limits: agent.budget,
|
|
139
|
+
progressNote: 'Execution counts and repeated results are diagnostics, not proof of semantic progress or stalling.',
|
|
140
|
+
} : null,
|
|
134
141
|
msSinceLastEvent: liveness.msSinceLastEvent ?? msSinceActivity,
|
|
135
142
|
stale,
|
|
136
143
|
stalled: stale,
|