@lmzhen/dsh-evolution-core 0.27.3 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/{http-routes-BP-S3KV-.js → http-routes-w09FdvOp.js} +6 -0
- package/lib/http-routes.js +1 -1
- package/lib/index.js +3334 -2629
- package/lib/subagent/index.js +240 -0
- package/lib/types/http-routes.d.ts +6 -0
- package/lib/types/http-routes.js +6 -0
- package/lib/types/index.d.ts +3 -0
- package/lib/types/index.js +3 -0
- package/lib/types/params.d.ts +1 -1
- package/lib/types/params.js +6 -0
- package/lib/types/run-registry.d.ts +40 -10
- package/lib/types/run-registry.js +92 -18
- package/lib/types/skill-history.d.ts +1 -1
- package/lib/types/skill-history.js +2 -1
- package/lib/types/skill-store.d.ts +61 -2
- package/lib/types/skill-store.js +239 -25
- package/lib/types/subagent/channel.js +202 -0
- package/lib/types/subagent/types.d.ts +39 -0
- package/lib/types/tree-lock.d.ts +51 -0
- package/lib/types/tree-lock.js +115 -0
- package/lib/types/unit-undo-plan.d.ts +48 -0
- package/lib/types/unit-undo-plan.js +94 -0
- package/lib/types/unit-undo.d.ts +28 -0
- package/lib/types/unit-undo.js +81 -0
- package/package.json +1 -1
- package/persisted-write-inventory.json +36 -0
|
@@ -37,7 +37,7 @@ import { transactIo, type EvolutionIoLike } from './io.ts';
|
|
|
37
37
|
import { type Probe } from './probe.ts';
|
|
38
38
|
import type { SkillLimits } from './limits.ts';
|
|
39
39
|
import { type MutationRecord } from './mutations.ts';
|
|
40
|
-
import { type SkillVersion } from './skill-history.ts';
|
|
40
|
+
import { type UnitHead, type SkillVersion } from './skill-history.ts';
|
|
41
41
|
import { type SkillHealthAssessment, type SkillHealthThresholds } from './skill-health.ts';
|
|
42
42
|
import type { EvolutionSkillMutatedEvent } from './events.ts';
|
|
43
43
|
export interface SkillSummary {
|
|
@@ -174,6 +174,39 @@ export interface ArchiveOptions {
|
|
|
174
174
|
reason?: string;
|
|
175
175
|
/** Permit archiving a bundled skill (curator prune-builtins only; hub-installed and pinned stay protected). */
|
|
176
176
|
allowBundled?: boolean;
|
|
177
|
+
/** Unit shared with the rest of a multi-skill transaction (merge). */
|
|
178
|
+
unitId?: string;
|
|
179
|
+
/** Cross-skill landmark for the unit; a merge source names the target. */
|
|
180
|
+
unitHead?: UnitHead;
|
|
181
|
+
}
|
|
182
|
+
/** One multi-skill merge commit: target body + support files, with the source names it absorbs. */
|
|
183
|
+
export interface CommitMergePlan {
|
|
184
|
+
readonly target: string;
|
|
185
|
+
/** Whether the target already exists. The caller planned this; commit re-checks it. */
|
|
186
|
+
readonly targetExists: boolean;
|
|
187
|
+
readonly sources: readonly string[];
|
|
188
|
+
readonly body: string;
|
|
189
|
+
readonly supportFiles: readonly {
|
|
190
|
+
readonly path: string;
|
|
191
|
+
readonly content: string;
|
|
192
|
+
}[];
|
|
193
|
+
/** Plan-time target body; null for a create. */
|
|
194
|
+
readonly expectedTargetBody: string | null;
|
|
195
|
+
readonly expectedSourceBodies: readonly {
|
|
196
|
+
readonly name: string;
|
|
197
|
+
readonly body: string;
|
|
198
|
+
}[];
|
|
199
|
+
readonly origin: WriteOrigin;
|
|
200
|
+
readonly summary: string;
|
|
201
|
+
}
|
|
202
|
+
export interface CommitMergeResult {
|
|
203
|
+
readonly ok: boolean;
|
|
204
|
+
readonly message: string;
|
|
205
|
+
readonly unitId?: string;
|
|
206
|
+
readonly targetPath?: string;
|
|
207
|
+
readonly archived?: readonly string[];
|
|
208
|
+
/** One per skill written: target + each source. */
|
|
209
|
+
readonly mutations?: number;
|
|
177
210
|
}
|
|
178
211
|
export declare function skillsRoot(env?: NodeJS.ProcessEnv): string;
|
|
179
212
|
/** 0.3.18 (S4.1, E-30): the ONE root resolution for every member that reads
|
|
@@ -270,6 +303,19 @@ export declare function markerEntryName(marker: 'bundled' | 'hub-installed' | 'p
|
|
|
270
303
|
* rule, or it creates an orphan the later patch/write/remove paths refuse.
|
|
271
304
|
* Single source shared with the plan validator. */
|
|
272
305
|
export declare function validateRestructureTarget(filePath: string): string | null;
|
|
306
|
+
export declare function validateSupportPath(filePath: string): string | null;
|
|
307
|
+
/**
|
|
308
|
+
* Support-directory references in a markdown body (009 kernel): `references/…`,
|
|
309
|
+
* `templates/…`, `scripts/…`, `assets/…` relative links — any extension and
|
|
310
|
+
* nested paths (v7 audit P3-1: `.md`-only matching missed `scripts/run.sh` and
|
|
311
|
+
* `references/sub/x.md`, both of which dangle just like a `.md` link once the
|
|
312
|
+
* source package is archived). Pure — who checks them and what the verdict is
|
|
313
|
+
* belongs to the caller's context (a moved/appended body whose references
|
|
314
|
+
* travel with an archived source is a dangling link; a restructure pointer is
|
|
315
|
+
* a fresh link to a file written in the same plan). `..` traversal is a
|
|
316
|
+
* different defect class (path validation owns it) and is not a support link.
|
|
317
|
+
*/
|
|
318
|
+
export declare function supportRefs(content: string): string[];
|
|
273
319
|
export declare class SkillLibrary {
|
|
274
320
|
readonly root: string;
|
|
275
321
|
readonly limits: SkillLimits;
|
|
@@ -487,7 +533,10 @@ export declare class SkillLibrary {
|
|
|
487
533
|
*/
|
|
488
534
|
setPinned(name: string, pinned: boolean, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
489
535
|
private setPinnedCore;
|
|
490
|
-
create(name: string, content: string, origin?: WriteOrigin
|
|
536
|
+
create(name: string, content: string, origin?: WriteOrigin, options?: {
|
|
537
|
+
unitId?: string;
|
|
538
|
+
unitHead?: UnitHead;
|
|
539
|
+
}): Promise<SkillActionResult>;
|
|
491
540
|
private createCore;
|
|
492
541
|
update(rawName: string, content: string, origin?: WriteOrigin, anchor?: WriteAnchor): Promise<SkillActionResult>;
|
|
493
542
|
private updateCore;
|
|
@@ -519,6 +568,16 @@ export declare class SkillLibrary {
|
|
|
519
568
|
* instead of throwing `name.includes is not a function`. */
|
|
520
569
|
private safeSnapshotEntryName;
|
|
521
570
|
archive(rawName: string, options?: ArchiveOptions, origin?: WriteOrigin): Promise<SkillActionResult>;
|
|
571
|
+
/**
|
|
572
|
+
* Commit one merge plan as a single multi-skill transaction.
|
|
573
|
+
*
|
|
574
|
+
* Existing target: archive every source first, then write the target through the
|
|
575
|
+
* tree-change kernel. Create target: create the target with its final body first
|
|
576
|
+
* (so absorbedInto can see it), then archive sources and write support files.
|
|
577
|
+
* Every failure rolls back what already landed; a rollback failure names the
|
|
578
|
+
* unrestored skills instead of claiming success.
|
|
579
|
+
*/
|
|
580
|
+
commitMerge(plan: CommitMergePlan): Promise<CommitMergeResult>;
|
|
522
581
|
/**
|
|
523
582
|
* V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
|
|
524
583
|
* computed BEFORE any side effect. `blocked` means behaviour is exactly the
|
package/lib/types/skill-store.js
CHANGED
|
@@ -352,7 +352,7 @@ export function validateRestructureTarget(filePath) {
|
|
|
352
352
|
return `toFile "${filePath}" uses a Windows reserved device name.`;
|
|
353
353
|
return null;
|
|
354
354
|
}
|
|
355
|
-
function validateSupportPath(filePath) {
|
|
355
|
+
export function validateSupportPath(filePath) {
|
|
356
356
|
const normalized = filePath.replace(/\\/g, '/');
|
|
357
357
|
if (normalized.includes('..'))
|
|
358
358
|
return '不允许路径穿越。';
|
|
@@ -393,7 +393,7 @@ function validateSupportPath(filePath) {
|
|
|
393
393
|
* a fresh link to a file written in the same plan). `..` traversal is a
|
|
394
394
|
* different defect class (path validation owns it) and is not a support link.
|
|
395
395
|
*/
|
|
396
|
-
function supportRefs(content) {
|
|
396
|
+
export function supportRefs(content) {
|
|
397
397
|
const refs = [];
|
|
398
398
|
for (const match of content.matchAll(/\b(?:references|templates|scripts|assets)\/[A-Za-z0-9._/-]+/g)) {
|
|
399
399
|
const ref = match[0];
|
|
@@ -1338,15 +1338,15 @@ export class SkillLibrary {
|
|
|
1338
1338
|
path: dir,
|
|
1339
1339
|
};
|
|
1340
1340
|
}
|
|
1341
|
-
async create(name, content, origin = this.authority.origin) {
|
|
1341
|
+
async create(name, content, origin = this.authority.origin, options = {}) {
|
|
1342
1342
|
const normalized = name.trim();
|
|
1343
1343
|
// P2-3 (v11): the exists-probe → write window runs under the in-process
|
|
1344
1344
|
// serialize queue (F-208 discipline, like update/patch) — two concurrent
|
|
1345
1345
|
// create calls for one name used to both pass exists() and double-write
|
|
1346
1346
|
// (double audit, double mutation event).
|
|
1347
|
-
return await this.serial(() => this.createCore(normalized, content, origin));
|
|
1347
|
+
return await this.serial(() => this.createCore(normalized, content, origin, options));
|
|
1348
1348
|
}
|
|
1349
|
-
async createCore(normalized, content, origin) {
|
|
1349
|
+
async createCore(normalized, content, origin, options = {}) {
|
|
1350
1350
|
// C-12 (v10 audit): the inline badName() copy is gone (see setPinned).
|
|
1351
1351
|
const bad = this.badName(normalized);
|
|
1352
1352
|
if (bad)
|
|
@@ -1511,7 +1511,7 @@ export class SkillLibrary {
|
|
|
1511
1511
|
return { ok: false, message: `创建过程中技能「${normalized}」被并发归档;残留标记已移除:等搬运完成后重试一次。` };
|
|
1512
1512
|
}
|
|
1513
1513
|
}
|
|
1514
|
-
await this.audit(normalized, 'create', null, onDisk, 'created');
|
|
1514
|
+
await this.audit(normalized, 'create', null, onDisk, 'created', undefined, options.unitId, options.unitHead);
|
|
1515
1515
|
this.notifyMutation({ action: 'create', name: normalized, skillDir: dir });
|
|
1516
1516
|
// Both warnings ride the normal success path (audit/event included) — a
|
|
1517
1517
|
// durability warning must never skip the accounting.
|
|
@@ -1919,10 +1919,222 @@ export class SkillLibrary {
|
|
|
1919
1919
|
catch {
|
|
1920
1920
|
// Same posture as .archive-reason: metadata only, never abort the archive.
|
|
1921
1921
|
}
|
|
1922
|
-
await this.audit(name, 'archive', md, null, reason, undefined,
|
|
1922
|
+
await this.audit(name, 'archive', md, null, reason, undefined, options.unitId, options.unitHead, origin);
|
|
1923
1923
|
this.notifyMutation({ action: 'archive', name, archivedPath: dest });
|
|
1924
1924
|
return { ok: true, message: `技能「${name}」已归档到 .archive。`, path: dest };
|
|
1925
1925
|
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Commit one merge plan as a single multi-skill transaction.
|
|
1928
|
+
*
|
|
1929
|
+
* Existing target: archive every source first, then write the target through the
|
|
1930
|
+
* tree-change kernel. Create target: create the target with its final body first
|
|
1931
|
+
* (so absorbedInto can see it), then archive sources and write support files.
|
|
1932
|
+
* Every failure rolls back what already landed; a rollback failure names the
|
|
1933
|
+
* unrestored skills instead of claiming success.
|
|
1934
|
+
*/
|
|
1935
|
+
async commitMerge(plan) {
|
|
1936
|
+
const target = plan.target.trim();
|
|
1937
|
+
const sources = [...new Set(plan.sources.map(name => name.trim()))].filter(name => name !== target);
|
|
1938
|
+
if (sources.length === 0)
|
|
1939
|
+
return { ok: false, message: '合并至少需要一个互不相同的来源技能。' };
|
|
1940
|
+
for (const name of [target, ...sources]) {
|
|
1941
|
+
const bad = this.badName(name);
|
|
1942
|
+
if (bad)
|
|
1943
|
+
return { ok: false, message: bad };
|
|
1944
|
+
}
|
|
1945
|
+
return await this.serial(async () => {
|
|
1946
|
+
const dir = this.dirOf(target);
|
|
1947
|
+
const targetPath = join(dir, 'SKILL.md');
|
|
1948
|
+
const currentTarget = await this.io.readText(targetPath);
|
|
1949
|
+
if (plan.targetExists !== (currentTarget !== null)) {
|
|
1950
|
+
return { ok: false, message: plan.targetExists ? `目标技能「${target}」不存在。` : `目标技能「${target}」已存在。` };
|
|
1951
|
+
}
|
|
1952
|
+
if (currentTarget !== plan.expectedTargetBody) {
|
|
1953
|
+
return { ok: false, message: `目标技能「${target}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
|
|
1954
|
+
}
|
|
1955
|
+
const targetProtection = await this.writeProtection(target, plan.origin);
|
|
1956
|
+
if (targetProtection)
|
|
1957
|
+
return { ok: false, message: `技能「${target}」受保护(${targetProtection})。` };
|
|
1958
|
+
const expectedSources = new Map(plan.expectedSourceBodies.map(entry => [entry.name.trim(), entry.body]));
|
|
1959
|
+
const sourceBodies = new Map();
|
|
1960
|
+
for (const source of sources) {
|
|
1961
|
+
const protection = await this.deleteProtection(source);
|
|
1962
|
+
if (protection)
|
|
1963
|
+
return { ok: false, message: `技能「${source}」受保护(${protection})。` };
|
|
1964
|
+
const body = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
1965
|
+
if (body === null)
|
|
1966
|
+
return { ok: false, message: `来源技能「${source}」不存在。` };
|
|
1967
|
+
const expected = expectedSources.get(source);
|
|
1968
|
+
if (expected === undefined)
|
|
1969
|
+
return { ok: false, message: `来源技能「${source}」缺少计划字节;拒绝提交。` };
|
|
1970
|
+
if (body !== expected)
|
|
1971
|
+
return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
|
|
1972
|
+
sourceBodies.set(source, body);
|
|
1973
|
+
}
|
|
1974
|
+
const validation = validateFrontmatter(plan.body, target, this.limits);
|
|
1975
|
+
if (validation)
|
|
1976
|
+
return { ok: false, message: validation };
|
|
1977
|
+
const supportPaths = new Set();
|
|
1978
|
+
const supportPrevious = new Map();
|
|
1979
|
+
for (const file of plan.supportFiles) {
|
|
1980
|
+
const badPath = validateSupportPath(file.path);
|
|
1981
|
+
if (badPath)
|
|
1982
|
+
return { ok: false, message: badPath };
|
|
1983
|
+
if (supportPaths.has(file.path))
|
|
1984
|
+
return { ok: false, message: `支撑文件重复:${file.path}` };
|
|
1985
|
+
supportPaths.add(file.path);
|
|
1986
|
+
if (Buffer.byteLength(file.content, 'utf8') > this.limits.maxSkillFileBytes)
|
|
1987
|
+
return { ok: false, message: `支撑文件超过 ${this.limits.maxSkillFileBytes} 字节:${file.path}` };
|
|
1988
|
+
const threat = this.contentThreatBlock(file.content);
|
|
1989
|
+
if (threat)
|
|
1990
|
+
return { ok: false, message: threat };
|
|
1991
|
+
const previous = await this.io.readText(join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)));
|
|
1992
|
+
supportPrevious.set(file.path, previous);
|
|
1993
|
+
}
|
|
1994
|
+
for (const ref of supportRefs(plan.body)) {
|
|
1995
|
+
if (supportPaths.has(ref))
|
|
1996
|
+
continue;
|
|
1997
|
+
const existing = plan.targetExists
|
|
1998
|
+
? await this.io.exists(join(dir, ...ref.replace(/\\/g, '/').split('/').filter(Boolean)))
|
|
1999
|
+
: false;
|
|
2000
|
+
if (!existing)
|
|
2001
|
+
return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
|
|
2002
|
+
}
|
|
2003
|
+
const unitId = 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
|
|
2004
|
+
const supportMoves = plan.supportFiles.map((file) => {
|
|
2005
|
+
const previous = supportPrevious.get(file.path) ?? null;
|
|
2006
|
+
return {
|
|
2007
|
+
path: file.path,
|
|
2008
|
+
kind: previous === null ? 'created' : 'moved',
|
|
2009
|
+
to: file.path,
|
|
2010
|
+
...(previous === null ? {} : { beforeRef: contentHash(previous) }),
|
|
2011
|
+
afterRef: contentHash(file.content),
|
|
2012
|
+
};
|
|
2013
|
+
});
|
|
2014
|
+
const unitHead = {
|
|
2015
|
+
moves: supportMoves,
|
|
2016
|
+
sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
|
|
2017
|
+
};
|
|
2018
|
+
const archived = [];
|
|
2019
|
+
const rollbackSources = async () => {
|
|
2020
|
+
const stuck = [];
|
|
2021
|
+
for (const source of [...archived].reverse()) {
|
|
2022
|
+
try {
|
|
2023
|
+
const restored = await this.restoreFromArchive(source, plan.origin);
|
|
2024
|
+
if (!restored.ok)
|
|
2025
|
+
stuck.push(source);
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
stuck.push(source);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return stuck;
|
|
2032
|
+
};
|
|
2033
|
+
const rollbackMessage = (reason, stuck) => ({
|
|
2034
|
+
ok: false,
|
|
2035
|
+
message: stuck.length === 0
|
|
2036
|
+
? `合并失败,已回滚:${reason}`
|
|
2037
|
+
: `合并失败(${reason});已回滚,但以下未回滚:${stuck.join(', ')}:它们仍在 .archive 里,请用 /evolution skill restore 恢复。`,
|
|
2038
|
+
});
|
|
2039
|
+
if (plan.targetExists) {
|
|
2040
|
+
for (const source of sources) {
|
|
2041
|
+
const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2042
|
+
if (fresh !== sourceBodies.get(source)) {
|
|
2043
|
+
const stuck = await rollbackSources();
|
|
2044
|
+
return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
|
|
2045
|
+
}
|
|
2046
|
+
const result = await this.archive(source, {
|
|
2047
|
+
absorbedInto: target,
|
|
2048
|
+
unitId,
|
|
2049
|
+
unitHead: { skill: target },
|
|
2050
|
+
reason: `Merged into ${target}`,
|
|
2051
|
+
}, plan.origin);
|
|
2052
|
+
if (!result.ok) {
|
|
2053
|
+
const stuck = await rollbackSources();
|
|
2054
|
+
return rollbackMessage(result.message, stuck);
|
|
2055
|
+
}
|
|
2056
|
+
archived.push(source);
|
|
2057
|
+
}
|
|
2058
|
+
const writes = [
|
|
2059
|
+
{ target: targetPath, content: plan.body.trimEnd() + '\n', expected: plan.expectedTargetBody },
|
|
2060
|
+
...plan.supportFiles.map(file => ({
|
|
2061
|
+
target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
|
|
2062
|
+
content: file.content,
|
|
2063
|
+
expected: supportPrevious.get(file.path) ?? null,
|
|
2064
|
+
})),
|
|
2065
|
+
];
|
|
2066
|
+
const result = await this.applyTreeChange({
|
|
2067
|
+
name: target,
|
|
2068
|
+
origin: plan.origin,
|
|
2069
|
+
protection: 'write',
|
|
2070
|
+
writes,
|
|
2071
|
+
auditAction: 'merge',
|
|
2072
|
+
auditSummary: plan.summary,
|
|
2073
|
+
eventAction: 'merge',
|
|
2074
|
+
unitId,
|
|
2075
|
+
unitHead,
|
|
2076
|
+
});
|
|
2077
|
+
if (!result.ok) {
|
|
2078
|
+
const stuck = await rollbackSources();
|
|
2079
|
+
return rollbackMessage(result.message, stuck);
|
|
2080
|
+
}
|
|
2081
|
+
return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
|
|
2082
|
+
}
|
|
2083
|
+
const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead });
|
|
2084
|
+
if (!created.ok)
|
|
2085
|
+
return { ok: false, message: created.message };
|
|
2086
|
+
for (const source of sources) {
|
|
2087
|
+
const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2088
|
+
if (fresh !== sourceBodies.get(source)) {
|
|
2089
|
+
const stuck = await rollbackSources();
|
|
2090
|
+
const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
|
|
2091
|
+
if (!targetRollback.ok)
|
|
2092
|
+
stuck.push(target);
|
|
2093
|
+
return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
|
|
2094
|
+
}
|
|
2095
|
+
const result = await this.archive(source, {
|
|
2096
|
+
absorbedInto: target,
|
|
2097
|
+
unitId,
|
|
2098
|
+
unitHead: { skill: target },
|
|
2099
|
+
reason: `Merged into ${target}`,
|
|
2100
|
+
}, plan.origin);
|
|
2101
|
+
if (!result.ok) {
|
|
2102
|
+
const stuck = await rollbackSources();
|
|
2103
|
+
const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
|
|
2104
|
+
if (!targetRollback.ok)
|
|
2105
|
+
stuck.push(target);
|
|
2106
|
+
return rollbackMessage(result.message, stuck);
|
|
2107
|
+
}
|
|
2108
|
+
archived.push(source);
|
|
2109
|
+
}
|
|
2110
|
+
if (plan.supportFiles.length > 0) {
|
|
2111
|
+
const writes = plan.supportFiles.map(file => ({
|
|
2112
|
+
target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
|
|
2113
|
+
content: file.content,
|
|
2114
|
+
expected: supportPrevious.get(file.path) ?? null,
|
|
2115
|
+
}));
|
|
2116
|
+
const result = await this.applyTreeChange({
|
|
2117
|
+
name: target,
|
|
2118
|
+
origin: plan.origin,
|
|
2119
|
+
protection: 'write',
|
|
2120
|
+
writes,
|
|
2121
|
+
auditAction: 'merge',
|
|
2122
|
+
auditSummary: plan.summary,
|
|
2123
|
+
eventAction: 'merge',
|
|
2124
|
+
unitId,
|
|
2125
|
+
unitHead,
|
|
2126
|
+
});
|
|
2127
|
+
if (!result.ok) {
|
|
2128
|
+
const stuck = await rollbackSources();
|
|
2129
|
+
const targetRollback = await this.archive(target, { reason: 'merge rolled back' }, plan.origin);
|
|
2130
|
+
if (!targetRollback.ok)
|
|
2131
|
+
stuck.push(target);
|
|
2132
|
+
return rollbackMessage(result.message, stuck);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
1926
2138
|
/**
|
|
1927
2139
|
* V2 (design §16.7): what an apply-mode consolidation would do for ONE source,
|
|
1928
2140
|
* computed BEFORE any side effect. `blocked` means behaviour is exactly the
|
|
@@ -2517,25 +2729,27 @@ export class SkillLibrary {
|
|
|
2517
2729
|
const isBody = land.target.split(/[\\/]/).pop() === 'SKILL.md';
|
|
2518
2730
|
const rel = isBody ? undefined : land.target.slice(dir.length + 1).split('\\').join('/');
|
|
2519
2731
|
// G3b(A22):代表片=第一条 landing;kind 判 null(previous 的取值域只有 string/null)。
|
|
2520
|
-
const unitHead = land === landing[0]
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2732
|
+
const unitHead = land === landing[0]
|
|
2733
|
+
? (plan.unitHead ?? {
|
|
2734
|
+
moves: landing
|
|
2735
|
+
.filter(l => l.target.split(/[\\/]/).pop() !== 'SKILL.md')
|
|
2736
|
+
.map((l) => {
|
|
2737
|
+
const p = l.target.slice(dir.length + 1).split(String.fromCharCode(92)).join(String.fromCharCode(47));
|
|
2738
|
+
return {
|
|
2739
|
+
path: p,
|
|
2740
|
+
kind: l.previous === null ? 'created' : 'moved',
|
|
2741
|
+
to: p,
|
|
2742
|
+
...l.previous === null ? {} : { beforeRef: contentHash(l.previous) },
|
|
2743
|
+
afterRef: contentHash(l.content),
|
|
2744
|
+
};
|
|
2745
|
+
})
|
|
2746
|
+
// F1 (independent review, 2026-10-14): when one plan writes the SAME path twice, keep the
|
|
2747
|
+
// LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
|
|
2748
|
+
// and `kind` are unaffected: both come from the same per-path pre-read.
|
|
2749
|
+
.filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
|
|
2750
|
+
sources: [{ skill: name }],
|
|
2532
2751
|
})
|
|
2533
|
-
|
|
2534
|
-
// LAST write -- first-wins left `afterRef` describing the earlier bytes (stale). `beforeRef`
|
|
2535
|
-
// and `kind` are unaffected: both come from the same per-path pre-read.
|
|
2536
|
-
.filter((m, idx, all) => all.map(x => x.path).lastIndexOf(m.path) === idx),
|
|
2537
|
-
sources: [{ skill: name }],
|
|
2538
|
-
} : undefined;
|
|
2752
|
+
: undefined;
|
|
2539
2753
|
// A22: previous is string | null (a read failure rejects the whole plan), so the old
|
|
2540
2754
|
// `=== undefined ? md : previous` was an unreachable branch -- the type is the truth.
|
|
2541
2755
|
const auditedBefore = land.previous;
|
|
@@ -6,8 +6,70 @@ function serviceOf(ctx, name) {
|
|
|
6
6
|
function messageOf(error) {
|
|
7
7
|
return error instanceof Error ? error.message : String(error);
|
|
8
8
|
}
|
|
9
|
+
function textOfValue(value) {
|
|
10
|
+
if (typeof value === 'string')
|
|
11
|
+
return value;
|
|
12
|
+
if (!Array.isArray(value))
|
|
13
|
+
return '';
|
|
14
|
+
return value
|
|
15
|
+
.filter(block => typeof block === 'object' && block !== null && block.type === 'text')
|
|
16
|
+
.map((block) => {
|
|
17
|
+
const text = block.text;
|
|
18
|
+
return typeof text === 'string' ? text : '';
|
|
19
|
+
})
|
|
20
|
+
.join('');
|
|
21
|
+
}
|
|
22
|
+
function abortLink(signal, timeoutMs) {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => { controller.abort(new Error('subagent turn timeout')); }, timeoutMs);
|
|
25
|
+
const onAbort = () => { controller.abort(signal?.reason); };
|
|
26
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
27
|
+
return {
|
|
28
|
+
controller,
|
|
29
|
+
cleanup: () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
signal?.removeEventListener('abort', onAbort);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function waitForSubagentEnd(ctx, childId, signal, timeoutMs) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const finish = (fn) => {
|
|
39
|
+
if (settled)
|
|
40
|
+
return;
|
|
41
|
+
settled = true;
|
|
42
|
+
cleanup();
|
|
43
|
+
fn();
|
|
44
|
+
};
|
|
45
|
+
const eventCtx = ctx;
|
|
46
|
+
const off = typeof eventCtx.on === 'function'
|
|
47
|
+
? eventCtx.on('subagent/end', (info) => {
|
|
48
|
+
if (info.id !== childId)
|
|
49
|
+
return;
|
|
50
|
+
finish(() => { resolve(info); });
|
|
51
|
+
})
|
|
52
|
+
: () => { };
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
finish(() => { reject(new Error('timed out waiting for subagent/end: ' + childId)); });
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
const onAbort = () => {
|
|
57
|
+
finish(() => { reject(signal.reason instanceof Error ? signal.reason : new Error('conversation turn aborted')); });
|
|
58
|
+
};
|
|
59
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
function cleanup() {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
signal.removeEventListener('abort', onAbort);
|
|
63
|
+
off();
|
|
64
|
+
}
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
onAbort();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
9
69
|
export function createSubagentChannel(ctx) {
|
|
10
70
|
let lastKind = 'reviewer';
|
|
71
|
+
const active = new Map();
|
|
72
|
+
const disposed = new Set();
|
|
11
73
|
const channel = {
|
|
12
74
|
get state() {
|
|
13
75
|
return probeChannelState(ctx, lastKind);
|
|
@@ -125,6 +187,146 @@ export function createSubagentChannel(ctx) {
|
|
|
125
187
|
}
|
|
126
188
|
}
|
|
127
189
|
},
|
|
190
|
+
async open(spec) {
|
|
191
|
+
lastKind = spec.kind;
|
|
192
|
+
const probe = probeChannelState(ctx, spec.kind);
|
|
193
|
+
if (probe.kind === 'unavailable')
|
|
194
|
+
return probe;
|
|
195
|
+
if (spec.allow.length === 0)
|
|
196
|
+
return { kind: 'unavailable', code: 'E-324', reason: 'allow set is empty' };
|
|
197
|
+
const subagents = serviceOf(ctx, 'subagents');
|
|
198
|
+
const presets = serviceOf(ctx, 'agentPresets');
|
|
199
|
+
if (subagents === undefined || presets === undefined) {
|
|
200
|
+
return { kind: 'unavailable', code: 'E-323', reason: 'subagents/agentPresets service unavailable' };
|
|
201
|
+
}
|
|
202
|
+
const parentPresetId = presets.composedPreset(spec.parent.ctx);
|
|
203
|
+
if (parentPresetId === undefined) {
|
|
204
|
+
return { kind: 'unavailable', code: 'E-323', reason: 'parent preset could not be resolved' };
|
|
205
|
+
}
|
|
206
|
+
const ensured = await ensureRestrictedPreset(ctx, { kind: spec.kind, parentPresetId, allow: spec.allow });
|
|
207
|
+
if (ensured.kind === 'failed')
|
|
208
|
+
return { kind: 'unavailable', code: 'E-324', reason: ensured.reason };
|
|
209
|
+
const link = abortLink(spec.signal, spec.timeoutMs);
|
|
210
|
+
let activation;
|
|
211
|
+
try {
|
|
212
|
+
activation = await subagents.startActivation({
|
|
213
|
+
provider: 'spawn',
|
|
214
|
+
label: spec.label,
|
|
215
|
+
delivery: 'caller',
|
|
216
|
+
request: {
|
|
217
|
+
parent: spec.parent,
|
|
218
|
+
prompt: [{ type: 'text', text: spec.promptText }],
|
|
219
|
+
agentPreset: ensured.presetId,
|
|
220
|
+
...(spec.cwd !== undefined ? { cwd: spec.cwd } : {}),
|
|
221
|
+
...(spec.maxDepth !== undefined ? { maxDepth: spec.maxDepth } : {}),
|
|
222
|
+
...(spec.agentOptions !== undefined ? { agentOptions: spec.agentOptions } : {}),
|
|
223
|
+
},
|
|
224
|
+
signal: link.controller.signal,
|
|
225
|
+
});
|
|
226
|
+
const result = await activation.result;
|
|
227
|
+
if (result.stopReason === 'aborted') {
|
|
228
|
+
try {
|
|
229
|
+
await activation.dispose();
|
|
230
|
+
}
|
|
231
|
+
catch { /* teardown must not replace the outcome */ }
|
|
232
|
+
return { kind: 'aborted' };
|
|
233
|
+
}
|
|
234
|
+
if (result.stopReason !== 'completed') {
|
|
235
|
+
try {
|
|
236
|
+
await activation.dispose();
|
|
237
|
+
}
|
|
238
|
+
catch { /* teardown must not replace the outcome */ }
|
|
239
|
+
return { kind: 'unavailable', code: 'E-323', reason: 'round 1 did not complete: ' + result.stopReason };
|
|
240
|
+
}
|
|
241
|
+
const turn = {
|
|
242
|
+
childId: activation.childId,
|
|
243
|
+
stopReason: result.stopReason,
|
|
244
|
+
text: textOfValue(result.output),
|
|
245
|
+
...(result.structured !== undefined ? { structured: result.structured } : {}),
|
|
246
|
+
...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
|
|
247
|
+
};
|
|
248
|
+
active.set(activation.childId, { childId: activation.childId, parent: spec.parent, activation, disposed: false });
|
|
249
|
+
disposed.delete(activation.childId);
|
|
250
|
+
return { kind: 'ok', childId: activation.childId, turn };
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (activation !== undefined) {
|
|
254
|
+
try {
|
|
255
|
+
await activation.dispose();
|
|
256
|
+
}
|
|
257
|
+
catch { /* teardown must not replace the outcome */ }
|
|
258
|
+
}
|
|
259
|
+
if (link.controller.signal.aborted)
|
|
260
|
+
return { kind: 'aborted' };
|
|
261
|
+
return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
link.cleanup();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
async continueTurn(childId, promptText, options) {
|
|
268
|
+
if (disposed.has(childId))
|
|
269
|
+
return { kind: 'unavailable', code: 'E-324', reason: 'conversation is disposed' };
|
|
270
|
+
const current = active.get(childId);
|
|
271
|
+
if (current === undefined || current.disposed) {
|
|
272
|
+
return { kind: 'unavailable', code: 'E-323', reason: 'no open conversation for ' + childId };
|
|
273
|
+
}
|
|
274
|
+
const subagents = serviceOf(ctx, 'subagents');
|
|
275
|
+
if (subagents?.sendMessage === undefined) {
|
|
276
|
+
return { kind: 'unavailable', code: 'E-323', reason: 'subagents.sendMessage unavailable' };
|
|
277
|
+
}
|
|
278
|
+
const link = abortLink(options?.signal, options?.timeoutMs ?? 120_000);
|
|
279
|
+
let pending;
|
|
280
|
+
try {
|
|
281
|
+
pending = waitForSubagentEnd(ctx, childId, link.controller.signal, options?.timeoutMs ?? 120_000);
|
|
282
|
+
await subagents.sendMessage(current.parent, childId, [{ type: 'text', text: promptText }], { signal: link.controller.signal });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
link.cleanup();
|
|
286
|
+
if (options?.signal?.aborted === true)
|
|
287
|
+
return { kind: 'aborted' };
|
|
288
|
+
return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const info = await pending;
|
|
292
|
+
if (info.stopReason === 'aborted')
|
|
293
|
+
return { kind: 'aborted' };
|
|
294
|
+
const turn = {
|
|
295
|
+
childId,
|
|
296
|
+
stopReason: info.stopReason ?? 'completed',
|
|
297
|
+
text: textOfValue(info.lastAssistantMessage),
|
|
298
|
+
...(info.diagnostic !== undefined ? { diagnostic: info.diagnostic } : {}),
|
|
299
|
+
};
|
|
300
|
+
return { kind: 'ok', childId, turn };
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (options?.signal?.aborted === true)
|
|
304
|
+
return { kind: 'aborted' };
|
|
305
|
+
return { kind: 'unavailable', code: 'E-323', reason: messageOf(error) };
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
link.cleanup();
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
async dispose(childId) {
|
|
312
|
+
const current = active.get(childId);
|
|
313
|
+
if (current === undefined || current.disposed) {
|
|
314
|
+
disposed.add(childId);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
current.disposed = true;
|
|
318
|
+
active.delete(childId);
|
|
319
|
+
disposed.add(childId);
|
|
320
|
+
try {
|
|
321
|
+
await current.activation.dispose();
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
try {
|
|
325
|
+
ctx.logger.warn('subagent conversation: dispose failed: ' + messageOf(error));
|
|
326
|
+
}
|
|
327
|
+
catch { /* logging must not mask teardown */ }
|
|
328
|
+
}
|
|
329
|
+
},
|
|
128
330
|
};
|
|
129
331
|
return channel;
|
|
130
332
|
}
|