dsh-punky-swarm 0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +661 -0
  3. package/README.en.md +281 -0
  4. package/README.md +281 -0
  5. package/cordis.patch.yml +59 -0
  6. package/lib/acps/certs.js +310 -0
  7. package/lib/acps/discovery-client.js +283 -0
  8. package/lib/acps/registry-client.js +527 -0
  9. package/lib/acps/server.js +302 -0
  10. package/lib/aip/agent-descriptor.js +170 -0
  11. package/lib/aip/identity.js +469 -0
  12. package/lib/aip/tool-descriptor.js +93 -0
  13. package/lib/api.js +266 -0
  14. package/lib/artifact-types.js +48 -0
  15. package/lib/assembly/audit-blind-review.js +170 -0
  16. package/lib/assembly/schema.js +253 -0
  17. package/lib/assembly.js +41 -0
  18. package/lib/assets.js +103 -0
  19. package/lib/bridge/trajectory.js +202 -0
  20. package/lib/client.js +704 -0
  21. package/lib/comms/acps-bridge.js +219 -0
  22. package/lib/comms/aip-format.js +141 -0
  23. package/lib/comms/budget.js +124 -0
  24. package/lib/comms/mailbox.js +221 -0
  25. package/lib/comms/topic-runtime.js +76 -0
  26. package/lib/comms/topic.js +115 -0
  27. package/lib/discovery/filter.js +199 -0
  28. package/lib/discovery/schema.js +157 -0
  29. package/lib/discovery/service.js +251 -0
  30. package/lib/hot/config-watch.js +252 -0
  31. package/lib/index.js +433 -0
  32. package/lib/lock.js +69 -0
  33. package/lib/panel/batch-detail.js +163 -0
  34. package/lib/panel/batch-list.js +84 -0
  35. package/lib/panel/locales.js +93 -0
  36. package/lib/panel/main.js +270 -0
  37. package/lib/panel/stream.js +348 -0
  38. package/lib/panel/theme.js +103 -0
  39. package/lib/panel/widgets.js +63 -0
  40. package/lib/schema.d.ts +192 -0
  41. package/lib/schema.js +259 -0
  42. package/lib/schema.ts +398 -0
  43. package/lib/state/archive.js +174 -0
  44. package/lib/state/command-exec.js +148 -0
  45. package/lib/state/constants.js +37 -0
  46. package/lib/state/corrupt-registry.js +103 -0
  47. package/lib/state/event-types.js +65 -0
  48. package/lib/state/gates.d.ts +226 -0
  49. package/lib/state/gates.js +428 -0
  50. package/lib/state/gates.ts +348 -0
  51. package/lib/state/machine-rules.d.ts +14 -0
  52. package/lib/state/machine-rules.js +76 -0
  53. package/lib/state/machine-rules.ts +88 -0
  54. package/lib/state/machine.js +68 -0
  55. package/lib/state/resume.js +201 -0
  56. package/lib/state/schema-v3.d.ts +10 -0
  57. package/lib/state/schema-v3.js +57 -0
  58. package/lib/state/schema-v3.ts +80 -0
  59. package/lib/state/store.js +607 -0
  60. package/lib/state/task-utils.js +40 -0
  61. package/lib/tools/core.js +323 -0
  62. package/lib/tools/git-utils.js +44 -0
  63. package/lib/tools/lane-tools.js +455 -0
  64. package/lib/tools/log-tools.js +173 -0
  65. package/lib/tools/mailbox-tools.js +121 -0
  66. package/lib/tools/merge-agent.js +127 -0
  67. package/lib/tools/register.js +85 -0
  68. package/lib/tools/shared.js +32 -0
  69. package/lib/types/contracts.d.ts +277 -0
  70. package/lib/types/contracts.js +17 -0
  71. package/lib/types/contracts.ts +279 -0
  72. package/lib/verify/evidence.js +249 -0
  73. package/lib/verify/gate.js +161 -0
  74. package/lib/verify/mount.js +48 -0
  75. package/lib/verify/selector.js +101 -0
  76. package/lib/watch/lane-heartbeat.js +333 -0
  77. package/lib/wave-plan.d.ts +48 -0
  78. package/lib/wave-plan.js +473 -0
  79. package/lib/wave-plan.ts +465 -0
  80. package/package.json +71 -0
  81. package/presets/jiufeng/NOTICE +5 -0
  82. package/presets/jiufeng/agent.cordis.yml +275 -0
  83. package/presets/jiufeng/preset.yml +3 -0
  84. package/skills/jiufeng-team/SKILL.md +214 -0
  85. package/skills/jiufeng-team/references/constitution.md +62 -0
  86. package/skills/jiufeng-team/references/roles/coder.md +17 -0
  87. package/skills/jiufeng-team/references/roles/coordinator.md +13 -0
  88. package/skills/jiufeng-team/references/roles/designer.md +14 -0
  89. package/skills/jiufeng-team/references/roles/doc-manager.md +13 -0
  90. package/skills/jiufeng-team/references/roles/manager.md +22 -0
  91. package/skills/jiufeng-team/references/roles/reviewer.md +19 -0
  92. package/skills/jiufeng-team/references/roles/supervisor.md +13 -0
  93. package/skills/jiufeng-team/references/roles/tester.md +13 -0
  94. package/skills/jiufeng-team/references/templates/call-chain-matrix-template.md +121 -0
  95. package/skills/jiufeng-team/references/templates/endpoint-behavior-template.md +60 -0
  96. package/skills/jiufeng-team/references/templates/gap-list.json +33 -0
  97. package/skills/jiufeng-team/references/templates/leader-decision-pack.md +92 -0
  98. package/skills/jiufeng-team/references/templates/plan-template.md +61 -0
  99. package/skills/jiufeng-team/references/templates/success-pattern-seeds.md +126 -0
  100. package/skills/jiufeng-team/references/workflow.md +98 -0
  101. package/skills/jiufeng-team/scripts/check-v3-density.mjs +98 -0
@@ -0,0 +1,607 @@
1
+ /*
2
+ Copyright (C) 2025-2026 Punky
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ // BatchStore:批次状态文件唯一事实源(原子写 + 事件日志 + 状态机迁移 + 恢复语义)
19
+ // v2:批次绑定 session——root/sessions/<sessionId>/batches/*.json;存量 root/batches 迁移到 legacy
20
+ // Tier3:层间门禁(entry/exit/Plan 契约/complete)由 state/gates.js 承担,createStore 经 createGates(root) 注入调用
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import * as schema from '../schema.js';
24
+ import { createGates, isAbsPath } from './gates.js';
25
+ import { BATCH_SCHEMA_V3, migrateV2toV3, chainsDefaults, conditionDefaults } from './schema-v3.js';
26
+ import * as machine from './machine.js';
27
+ import { loadRules } from './machine-rules.js';
28
+ import { createArchive } from './archive.js'; // done→archive(complete 钩子)
29
+ import { createCorruptRegistry } from './corrupt-registry.js'; // 损坏批次旁路清单(v2-node-robustness ②)
30
+ import { SESSION_RE } from './constants.js'; // P1-07 单点(原本文件 :32 定义迁出)
31
+ import { laneProgressClear, laneProgressWrite } from './resume.js'; // P1-02 断点指针:结算终态清退 + 原子写合并(纯函数,本文件落盘)
32
+ // P1-04 单点:findTask 收敛至 task-utils.js(原 :430 本地定义删除)
33
+ import { findTask } from './task-utils.js';
34
+ // P2-07 事件 type 常量单点:newEvent 调用 type 一律引用本模块常量(禁止裸字面量)
35
+ import * as EVT from './event-types.js';
36
+
37
+ const STORE_SCHEMA = BATCH_SCHEMA_V3;
38
+
39
+ function atomicWrite(file, data) {
40
+ const dir = path.dirname(file);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ const tmp = path.join(dir, '.' + path.basename(file) + '.' + process.pid + '.tmp');
43
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
44
+ fs.renameSync(tmp, file); // Windows: MoveFileEx REPLACE_EXISTING
45
+ }
46
+
47
+ // C 子项:连续失败升级(spec C)——计数纯函数(顶层导出,供单测):
48
+ // 从事件流末尾向前扫描,统计连续 member.settled 且 to === 'failed' 的事件数(含本次刚 push 的结算);
49
+ // 遇 member.settled 但 to !== 'failed'(merged/conflict/skipped/review/running 等)即停止(归零语义 C-计数);
50
+ // 非 member.settled 事件(worktree.checkpoint / batch.phase / gate.* 等)不打断计数。
51
+ export function countConsecutiveFailedSettles(events) {
52
+ const evs = events ?? [];
53
+ let count = 0;
54
+ for (let i = evs.length - 1; i >= 0; i--) {
55
+ const e = evs[i];
56
+ if (!e || e.type !== EVT.EVT_MEMBER_SETTLED) continue;
57
+ if (e.to === 'failed') count++;
58
+ else break;
59
+ }
60
+ return count;
61
+ }
62
+
63
+ export function createStore(root, { rules, logger, onStateChange } = {}) {
64
+ const sessionsDir = path.join(root, 'sessions');
65
+ const legacyDir = path.join(root, 'batches');
66
+ const gates = createGates(root);
67
+ // 损坏批次旁路清单(②,D-001):与 governance.json 同层;批次 JSON 结构零变更
68
+ const corruptRegistry = createCorruptRegistry(root);
69
+ // 留痕日志(可选注入;缺省 console——与 index.js ctx.logger 解耦,保持 createStore 既有调用点零改动)
70
+ const log = logger ?? console;
71
+ // 棘轮规则表(createStore(root, { rules }) 可选注入 loadRules 产物;未注入 = 默认规则 = schema 常量同引用,行为不变)
72
+ const ratchet = rules ?? loadRules();
73
+ // 归档器装配(createStore 签名不变;归档目标 = <root>/sessions/<sid>/archive/<bid>/)
74
+ const archive = createArchive(root);
75
+
76
+ function sessionDir(sessionId) {
77
+ if (!SESSION_RE.test(sessionId)) throw new Error('invalid sessionId: ' + sessionId);
78
+ return path.join(sessionsDir, sessionId);
79
+ }
80
+ function batchesDirOf(sessionId) {
81
+ return path.join(sessionDir(sessionId), 'batches');
82
+ }
83
+ function artifactsDirOf(sessionId, batchId) {
84
+ return path.join(sessionDir(sessionId), 'artifacts', batchId);
85
+ }
86
+ // condition 校验的 fileExists DI(machine 纯逻辑,路径解析在本侧)——相对路径解析到批次产物根,绝对路径直接用;存在性判定(existsSync)
87
+ function conditionFileExists(sessionId, batchId) {
88
+ const artifactsDir = artifactsDirOf(sessionId, batchId);
89
+ return (p) => fs.existsSync(isAbsPath(p) ? p : path.join(artifactsDir, p));
90
+ }
91
+
92
+ // 存量迁移:root/batches/*.json -> sessions/legacy/batches/(一次,幂等)
93
+ function migrateLegacy() {
94
+ if (!fs.existsSync(legacyDir)) return 0;
95
+ let moved = 0;
96
+ for (const f of fs.readdirSync(legacyDir)) {
97
+ if (!f.endsWith('.json')) continue;
98
+ const dst = path.join(batchesDirOf('legacy'), f);
99
+ fs.mkdirSync(batchesDirOf('legacy'), { recursive: true });
100
+ fs.renameSync(path.join(legacyDir, f), dst);
101
+ moved++;
102
+ }
103
+ try { fs.rmdirSync(legacyDir); } catch {}
104
+ return moved;
105
+ }
106
+
107
+ function batchFile(sessionId, batchId) {
108
+ if (!SESSION_RE.test(batchId)) throw new Error('invalid batchId');
109
+ return path.join(batchesDirOf(sessionId), batchId + '.json');
110
+ }
111
+
112
+ function newEvent(type, fields = {}) {
113
+ return { ts: new Date().toISOString(), type, ...fields };
114
+ }
115
+
116
+ // R2 状态事件发布钩子(topic 接线):setMember/setPhase 调用点埋点——
117
+ // appendEvent 为闭包内部函数,外部 wrap 该导出属性无法拦截内部迁移(设计 §3.2.3 固化),
118
+ // 故必须在调用点埋(设计 §4.3/§5.3 风险点 2)。onStateChange 缺省未装配(topic 默认关)→ 零行为变化;
119
+ // 异常隔离(发布失败不阻断状态机)。载荷为纯数据摘要,topic 命名由装配侧(topic-runtime)负责。
120
+ function emitStateChange(ev) {
121
+ try { onStateChange?.(ev); } catch { /* 隔离:topic 发布失败不阻断状态机 */ }
122
+ }
123
+
124
+ function createBatch(sessionId, { batchId, wavePlan, concurrency = 5, phase = 'planning' }) {
125
+ schema.assertBatchPhase(phase);
126
+ const file = batchFile(sessionId, batchId);
127
+ if (fs.existsSync(file)) throw new Error('batch already exists: ' + batchId);
128
+ const lanes = {};
129
+ for (const w of wavePlan.wavePlan) {
130
+ for (const t of w.tasks) lanes[t.id] = 'pending';
131
+ }
132
+ const batch = {
133
+ schema: STORE_SCHEMA,
134
+ sessionId,
135
+ batchId,
136
+ phase,
137
+ concurrency,
138
+ team: wavePlan.team ?? 'generic',
139
+ wavePlan: wavePlan.wavePlan,
140
+ lanes,
141
+ chains: chainsDefaults(), // C4:mailbox 环防护记账状态(v3 字段,唯一事实源)
142
+ archived: false, // 单向归档标记(v3 可选字段,缺省 false;complete 归档后置 true)
143
+ events: [newEvent(EVT.EVT_BATCH_CREATED, { batchId, sessionId })],
144
+ createdAt: new Date().toISOString(),
145
+ updatedAt: new Date().toISOString(),
146
+ };
147
+ atomicWrite(file, batch);
148
+ return batch;
149
+ }
150
+
151
+ // 三态读取基础函数(readBatch 的语义来源,纯读取无副作用):
152
+ // { status:'ok', batch } | { status:'missing' } | { status:'corrupt', error }
153
+ function readBatchResult(sessionId, batchId) {
154
+ const file = batchFile(sessionId, batchId);
155
+ if (!fs.existsSync(file)) return { status: 'missing' };
156
+ try {
157
+ return { status: 'ok', batch: JSON.parse(fs.readFileSync(file, 'utf8')) };
158
+ } catch (err) {
159
+ return { status: 'corrupt', error: err };
160
+ }
161
+ }
162
+
163
+ function readBatch(sessionId, batchId) {
164
+ const res = readBatchResult(sessionId, batchId);
165
+ if (res.status === 'corrupt') {
166
+ // 损坏隔离(②,P0,D-001):幂等登记旁路清单;仅首次登记时 warn 留痕(INV-2 不重复刷日志);
167
+ // 损坏与不存在共用 null 返回值——需区分语义的调用方走 isCorrupt() 二次查询
168
+ const r = corruptRegistry.markBatchCorrupt(sessionId, batchId, res.error);
169
+ if (r.first) {
170
+ log.warn?.('[dsh-punky-swarm] corrupt batch isolated: ' + sessionId + '/' + batchId
171
+ + ' (' + ((res.error && res.error.message) || res.error) + ');已登记 corrupt-batches.json,'
172
+ + '修复/删除文件后经 clearCorruptMark 清除标记');
173
+ }
174
+ return null;
175
+ }
176
+ return res.batch; // ok → batch;missing → undefined(调用点按 null 语义处理)
177
+ }
178
+
179
+ // 区分「损坏」与「不存在」(readBatch 两者均返回 null)
180
+ function isCorrupt(sessionId, batchId) {
181
+ return corruptRegistry.isCorrupt(sessionId, batchId);
182
+ }
183
+
184
+ function listBatches(sessionId) {
185
+ const dir = batchesDirOf(sessionId);
186
+ if (!fs.existsSync(dir)) return [];
187
+ return fs.readdirSync(dir)
188
+ .filter((f) => f.endsWith('.json'))
189
+ .map((f) => f.slice(0, -5))
190
+ .sort();
191
+ }
192
+
193
+ function listSessions() {
194
+ if (!fs.existsSync(sessionsDir)) return [];
195
+ return fs.readdirSync(sessionsDir)
196
+ .filter((s) => SESSION_RE.test(s) && fs.existsSync(batchesDirOf(s)) && fs.readdirSync(batchesDirOf(s)).some((f) => f.endsWith('.json')))
197
+ .sort();
198
+ }
199
+
200
+ function setMember(sessionId, batchId, lane, to, note = null) {
201
+ let batch = readBatch(sessionId, batchId); // let:P1-02 终态清退经 laneProgressClear 返回新 batch(不突变入参)
202
+ if (!batch) throw new Error('batch not found: ' + batchId);
203
+ schema.assertMemberState(to);
204
+ if (!(lane in batch.lanes)) throw new Error('unknown lane: ' + lane);
205
+ const from = batch.lanes[lane];
206
+ // 迁移判定走 machine(rules 可注入;默认规则与 schema 常量同引用,行为不变)
207
+ const mv = machine.applyMemberTransition(from, to, { rules: ratchet });
208
+ if (!mv.ok) throw new Error('invalid member transition: ' + from + ' -> ' + to);
209
+ // Tier3 门禁:派发(condition + entry)与结算(exit/Plan 契约)+ needHuman(review 挂起检测 / merged 人工裁决闸)
210
+ if (to === 'running') {
211
+ // 派发前条件校验(lane.condition 静态声明,DI fileExists)——不满足 → 不派发、自动落 skipped(既有终态迁移)+ lane.skipped 事件;wavePlan 不动不重算
212
+ const cond = machine.checkDispatchCondition(sessionId, batchId, batch, lane, { fileExists: conditionFileExists(sessionId, batchId) });
213
+ if (!cond.ok) {
214
+ // 落 skipped 同样过棘轮表(fail-closed 优先:收紧配置删掉 from→skipped 时拒绝自动跳过,不绕过规则表)
215
+ const skip = machine.applyMemberTransition(from, 'skipped', { rules: ratchet });
216
+ if (!skip.ok) {
217
+ throw new Error('invalid member transition: ' + from + ' -> skipped (condition unmet: ' + cond.missing.join(', ') + '; ratchet forbids auto-skip)');
218
+ }
219
+ // P1-02:condition 自动 skipped 亦为结算终态——清退 laneProgress 断点指针(不残留脏指针)
220
+ batch = laneProgressClear(batch, lane);
221
+ batch.lanes[lane] = 'skipped'; // pending→skipped 既有合法迁移
222
+ batch.events.push(newEvent(EVT.EVT_LANE_SKIPPED, { lane, from, note: 'condition unmet: ' + cond.missing.join(', ') }));
223
+ batch.events.push(newEvent(EVT.EVT_MEMBER_SETTLED, { lane, from, to: 'skipped', note: 'condition unmet: ' + cond.missing.join(', ') }));
224
+ batch.updatedAt = new Date().toISOString();
225
+ atomicWrite(batchFile(sessionId, batchId), batch);
226
+ // R2 调用点埋点:condition 自动 skipped 亦为结算终态(member.settled 事件发布)
227
+ emitStateChange({ type: 'member.settled', sessionId, batchId, lane, from, to: 'skipped', note: 'condition unmet: ' + cond.missing.join(', ') });
228
+ return batch;
229
+ }
230
+ const g = gates.checkEntryGate(sessionId, batchId, batch, lane);
231
+ if (!g.ok) {
232
+ batch.events.push(newEvent(EVT.EVT_GATE_ENTRY_MISSING, { lane, missing: g.missing }));
233
+ batch.updatedAt = new Date().toISOString();
234
+ atomicWrite(batchFile(sessionId, batchId), batch);
235
+ throw new Error(g.code + ': ' + g.missing.join(', '));
236
+ }
237
+ }
238
+ if (to === 'review') {
239
+ // audit lane 产物含 needHuman 声明 → 事件 lane.needhuman 留痕(Manager 转达人工裁决)
240
+ const nh = gates.checkNeedHumanGate(sessionId, batchId, batch, lane, null);
241
+ if (nh.declared) batch.events.push(newEvent(EVT.EVT_LANE_NEEDHUMAN, { lane, path: nh.path }));
242
+ }
243
+ if (to === 'merged') {
244
+ const g = gates.checkExitGate(sessionId, batchId, batch, lane);
245
+ if (!g.ok) {
246
+ batch.events.push(newEvent(EVT.EVT_GATE_EXIT_MISSING, { lane, code: g.code, detail: g.problems ?? g.missing }));
247
+ batch.updatedAt = new Date().toISOString();
248
+ atomicWrite(batchFile(sessionId, batchId), batch);
249
+ throw new Error(g.code + ': ' + (g.problems ?? g.missing).join(', '));
250
+ }
251
+ batch.events.push(newEvent(EVT.EVT_GATE_PASSED, { lane, gate: 'exit' }));
252
+ // targets 门禁(O2,设计 §1.3):exec 层声明 targets(批次产物根外目标文件)→ merged 前置校验——
253
+ // exit gate 之后、command gate 之前(增量接线,不改既有门禁顺序与语义)。
254
+ // 失败(missing/unchanged)→ gate.target_blocked 事件 + 抛错拒 merged(lane 留 review,成员态不变,与 exit gate 同语义);
255
+ // 通过且 declared → gate.target.passed 事件留痕;未声明/非 exec/逃生阀 → 零感知(无事件)。
256
+ const tg = gates.checkTargetsGate(sessionId, batchId, batch, lane);
257
+ if (!tg.ok) {
258
+ batch.events.push(newEvent(EVT.EVT_GATE_TARGET_BLOCKED, { lane, code: tg.code, missing: tg.missing ?? [], unchanged: tg.unchanged ?? [] }));
259
+ batch.updatedAt = new Date().toISOString();
260
+ atomicWrite(batchFile(sessionId, batchId), batch);
261
+ throw new Error(tg.code + ': targets 未通过校验(missing=' + (tg.missing ?? []).join(', ') + '; unchanged=' + (tg.unchanged ?? []).join(', ') + ')');
262
+ }
263
+ if (tg.declared) {
264
+ batch.events.push(newEvent(EVT.EVT_GATE_TARGET_PASSED, { lane, mode: tg.mode ?? 'mtime', targets: tg.targets ?? [] }));
265
+ }
266
+ // 命令 gate(V1,设计 §组件 4):exec 层产物声明行 `gate: <命令>` → merged 前置确定性执行(checkExitGate 之后、needHuman 之前)
267
+ // 成功/未声明 → gate.exit 事件(declared 时)后继续;失败+needHuman 声明 → 转人工闸(escalation,merged 须 note 含 human: 证据);
268
+ // 失败+未声明 → gate.exit_blocked 事件 + 抛 GATE_EXIT_*(拒 merged,lane 留 review)
269
+ const cg = gates.checkCommandGate(sessionId, batchId, batch, lane);
270
+ if (!cg.ok) {
271
+ batch.events.push(newEvent(EVT.EVT_GATE_EXIT_BLOCKED, { lane, code: cg.code, command: cg.command ?? null, exitCode: cg.exitCode ?? null, detail: cg.detail ?? null, escalation: cg.needHumanEscalation === true }));
272
+ if (cg.needHumanEscalation) {
273
+ // 转人工闸:复用 needHuman 证据契约(note 含 `human:<裁决人>:<时间>:<结论>`);无证据 → GATE_NEEDHUMAN_PENDING 挂起
274
+ const evidence = typeof note === 'string' ? (note.match(/^human:.+/m) ?? [null])[0] : null;
275
+ if (!evidence) {
276
+ batch.updatedAt = new Date().toISOString();
277
+ atomicWrite(batchFile(sessionId, batchId), batch);
278
+ throw new Error('GATE_NEEDHUMAN_PENDING: 命令 gate 失败(' + cg.code + ')且产物声明 needHuman,merged 须 note 含 human: 证据');
279
+ }
280
+ batch.events.push(newEvent(EVT.EVT_HUMAN_DECISION, { lane, note })); // 人工裁决留痕(note 可回溯)
281
+ } else {
282
+ batch.updatedAt = new Date().toISOString();
283
+ atomicWrite(batchFile(sessionId, batchId), batch);
284
+ throw new Error(cg.code + ': ' + (cg.detail ?? 'command gate failed'));
285
+ }
286
+ } else if (cg.declared) {
287
+ batch.events.push(newEvent(EVT.EVT_GATE_EXIT, { lane, commands: cg.commands ?? [], results: cg.results ?? [], outputTruncated: cg.outputTruncated === true }));
288
+ }
289
+ // needHuman 人工闸(merged 前置,与 checkExitGate 并列)——声明 lane 缺 human: 证据 → 拒 GATE_NEEDHUMAN_PENDING
290
+ const nh = gates.checkNeedHumanGate(sessionId, batchId, batch, lane, note);
291
+ if (!nh.ok) {
292
+ batch.events.push(newEvent(EVT.EVT_GATE_NEEDHUMAN_BLOCKED, { lane, code: nh.code, path: nh.path }));
293
+ batch.updatedAt = new Date().toISOString();
294
+ atomicWrite(batchFile(sessionId, batchId), batch);
295
+ throw new Error(nh.code + ': ' + nh.message);
296
+ }
297
+ if (nh.declared) batch.events.push(newEvent(EVT.EVT_HUMAN_DECISION, { lane, note })); // 人工裁决留痕(note 可回溯)
298
+ }
299
+ // P1-02:lane 结算终态(merged/failed/skipped/conflict,member_settle 语义)清退 laneProgress
300
+ // 断点指针(不残留脏指针;批次 complete 后整块随批次归档由 archive 覆盖)
301
+ if (schema.isMemberTerminal(to)) batch = laneProgressClear(batch, lane);
302
+ batch.lanes[lane] = to;
303
+ batch.events.push(newEvent(EVT.EVT_MEMBER_SETTLED, { lane, from, to, note: note ?? null }));
304
+ // C 子项:连续失败升级(spec C)——failed 结算后计数:同批次最近连续 failed ≥3 且批次 running → 触发 paused。
305
+ // 单次原子写:paused 迁移 + batch.phase 事件 + batch.failed-escalate 事件与本次结算同批落盘(不调 setPhase 二次写盘,避免竞态)。
306
+ // 棘轮校验 fail-closed:running→paused 迁移被部署收紧删除时 bv.ok=false,不触发、不绕过棘轮;
307
+ // phase 闸(T-2):paused 后 phase 非 running 自然不重复;人工 resume 后计数从当前事件流重新评估;
308
+ // 不自动重试:failed 仍为终态(schema failed: [] 不变),重做=重开新批次。
309
+ let escalated = false;
310
+ if (to === 'failed' && batch.phase === 'running') {
311
+ const streak = countConsecutiveFailedSettles(batch.events);
312
+ if (streak >= 3) {
313
+ const bv = machine.applyBatchTransition('running', 'paused', { rules: ratchet });
314
+ if (bv.ok) {
315
+ batch.phase = 'paused';
316
+ batch.events.push(newEvent(EVT.EVT_BATCH_PHASE, { from: 'running', to: 'paused', reason: 'failed-escalate' }));
317
+ batch.events.push(newEvent(EVT.EVT_BATCH_FAILED_ESCALATE, { lane, count: streak }));
318
+ escalated = true;
319
+ }
320
+ }
321
+ }
322
+ batch.updatedAt = new Date().toISOString();
323
+ atomicWrite(batchFile(sessionId, batchId), batch);
324
+ // R2 调用点埋点:member.settled(结算终态/返工入 review 等全部迁移)+ 伴随的 batch.phase(failed-escalate)
325
+ emitStateChange({ type: 'member.settled', sessionId, batchId, lane, from, to, note: note ?? null });
326
+ if (escalated) {
327
+ emitStateChange({ type: 'batch.phase', sessionId, batchId, from: 'running', to: 'paused', reason: 'failed-escalate' });
328
+ }
329
+ return batch;
330
+ }
331
+
332
+ function setPhase(sessionId, batchId, to) {
333
+ const batch = readBatch(sessionId, batchId);
334
+ if (!batch) throw new Error('batch not found: ' + batchId);
335
+ schema.assertBatchPhase(to);
336
+ const from = batch.phase;
337
+ // 批次阶段迁移判定走 machine(rules 可注入;默认规则与 schema 常量同引用,行为不变)
338
+ const bv = machine.applyBatchTransition(from, to, { rules: ratchet });
339
+ if (!bv.ok) throw new Error('invalid batch phase transition: ' + from + ' -> ' + to);
340
+ if (to === 'complete') {
341
+ const g = gates.checkCompleteGate(batch);
342
+ if (!g.ok) {
343
+ batch.events.push(newEvent(EVT.EVT_GATE_COMPLETE_BLOCKED, { code: g.code, pending: g.pending }));
344
+ batch.updatedAt = new Date().toISOString();
345
+ atomicWrite(batchFile(sessionId, batchId), batch);
346
+ throw new Error(g.code + (g.pending ? ': ' + g.pending.join(', ') : ''));
347
+ }
348
+ }
349
+ batch.phase = to;
350
+ batch.events.push(newEvent(EVT.EVT_BATCH_PHASE, { from, to }));
351
+ batch.updatedAt = new Date().toISOString();
352
+ atomicWrite(batchFile(sessionId, batchId), batch);
353
+ // R2 调用点埋点:batch.phase 迁移事件发布(规划→运行→暂停→终态等全部阶段迁移)
354
+ emitStateChange({ type: 'batch.phase', sessionId, batchId, from, to });
355
+ // complete 钩子——门禁通过 + phase 写入后自动归档(单向、幂等);
356
+ // 失败仅记录 archive.failed(archiveBatch 内部处理),不阻断 complete;try/catch 兜底意外异常(如批次文件不可读)
357
+ if (to === 'complete') {
358
+ try {
359
+ archive.archiveBatch(sessionId, batchId);
360
+ } catch (err) {
361
+ try {
362
+ const b = readBatch(sessionId, batchId);
363
+ if (b) {
364
+ b.events.push(newEvent(EVT.EVT_ARCHIVE_FAILED, { reason: String((err && err.message) || err) }));
365
+ b.updatedAt = new Date().toISOString();
366
+ atomicWrite(batchFile(sessionId, batchId), b);
367
+ }
368
+ } catch { /* 兜底也失败:静默(complete 已置位,审计可经 archive/ 目录状态判断) */ }
369
+ }
370
+ }
371
+ return batch;
372
+ }
373
+
374
+ function appendEvent(sessionId, batchId, type, fields = {}) {
375
+ const batch = readBatch(sessionId, batchId);
376
+ if (!batch) throw new Error('batch not found: ' + batchId);
377
+ batch.events.push(newEvent(type, fields));
378
+ batch.updatedAt = new Date().toISOString();
379
+ atomicWrite(batchFile(sessionId, batchId), batch);
380
+ return batch;
381
+ }
382
+
383
+ // ---- C4 budget:chains 状态读写(批次 v3 字段,原子写复用 atomicWrite)----
384
+ // readChains:读 batch.chains;v2 存量批次经 migrateV2toV3 幂等补默认(只读不落盘)
385
+ function readChains(sessionId, batchId) {
386
+ const batch = readBatch(sessionId, batchId);
387
+ if (!batch) return null;
388
+ return migrateV2toV3(batch).chains ?? chainsDefaults();
389
+ }
390
+ // updateChains:patch = 完整新 chains 状态(recordChain 纯函数产出),原子写;v2 存量批次迁移落盘(schema 升 3 + chains 补全)
391
+ function updateChains(sessionId, batchId, patch) {
392
+ const batch = readBatch(sessionId, batchId);
393
+ if (!batch) throw new Error('batch not found: ' + batchId);
394
+ const next = migrateV2toV3(batch);
395
+ next.chains = patch ?? chainsDefaults();
396
+ next.updatedAt = new Date().toISOString();
397
+ atomicWrite(batchFile(sessionId, batchId), next);
398
+ return next.chains;
399
+ }
400
+
401
+ // ---- P1-02 断点指针持久化(laneProgress)----
402
+ // updateLaneProgress:lane_checkpoint 携带 progress 时经 laneProgressWrite 纯函数合并后原子写
403
+ // (不突变入参;worktree.checkpoint 事件留痕由调用方 lane-tools 承担,本接口只写指针)。
404
+ // 清退走 setMember 终态分支(laneProgressClear),本接口只增不删。
405
+ function updateLaneProgress(sessionId, batchId, lane, progress) {
406
+ const batch = readBatch(sessionId, batchId);
407
+ if (!batch) throw new Error('batch not found: ' + batchId);
408
+ const next = laneProgressWrite(batch, lane, progress);
409
+ next.updatedAt = new Date().toISOString();
410
+ atomicWrite(batchFile(sessionId, batchId), next);
411
+ return next;
412
+ }
413
+
414
+ // asset_claim 归位(设计 6.3/§4):Leader 已直做产物复制进批次 artifacts/<batchId>/(保留内容,不移动),事件留痕。
415
+ // 安全:target 必须是批次内相对路径——拒绝绝对路径、盘符前缀、.. / . / 空段;解析后仍须落在 artifacts 目录内(纵深防御)。
416
+ function claimAsset(sessionId, batchId, { source, target }) {
417
+ const batch = readBatch(sessionId, batchId);
418
+ if (!batch) throw new Error('batch not found: ' + batchId);
419
+ if (typeof target !== 'string' || !target.length) throw new Error('invalid target path: ' + target + ' (must be batch-relative)');
420
+ if (isAbsPath(target) || /^[A-Za-z]:/.test(target)) throw new Error('invalid target path: ' + target + ' (must be batch-relative, no absolute path)');
421
+ const segments = target.split(/[\\/]+/);
422
+ if (segments.some((s) => s === '' || s === '.' || s === '..')) {
423
+ throw new Error('invalid target path: ' + target + ' (must be batch-relative, no .. or empty segment)');
424
+ }
425
+ let st;
426
+ try { st = fs.statSync(source); } catch { throw new Error('source not found: ' + source); }
427
+ if (!st.isFile()) throw new Error('source is not a file: ' + source);
428
+ const artifactsDir = artifactsDirOf(sessionId, batchId);
429
+ const dest = path.join(artifactsDir, ...segments);
430
+ const base = path.resolve(artifactsDir) + path.sep;
431
+ if (!path.resolve(dest).startsWith(base)) throw new Error('target escapes artifacts dir: ' + target);
432
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
433
+ fs.copyFileSync(source, dest); // 保留内容,不移动(避免破坏已引用)
434
+ batch.events.push(newEvent(EVT.EVT_ASSET_CLAIMED, { lane: null, source, target }));
435
+ batch.updatedAt = new Date().toISOString();
436
+ atomicWrite(batchFile(sessionId, batchId), batch);
437
+ return { ok: true, claimedPath: target, batchId };
438
+ }
439
+
440
+ function batchSettled(batch) {
441
+ const lanes = Object.values(batch.lanes ?? {});
442
+ return batch.phase === 'running' && lanes.length > 0 && lanes.every(schema.isMemberTerminal);
443
+ }
444
+
445
+ function batchAutoReleaseable(batch) {
446
+ const lanes = Object.values(batch.lanes ?? {});
447
+ if (!batch || lanes.length === 0) return false;
448
+ return batch.phase === 'running' && lanes.every((s) => s === 'merged');
449
+ }
450
+
451
+ // ---- 恢复审计辅助(只读探测,无副作用)----
452
+ // lastActiveAt(上次活动时间):events 中该 lane 最近一条带 lane 字段事件(member.settled / worktree.checkpoint /
453
+ // lane.skipped / lane.needhuman 等)的 ts;无 lane 事件 → 回退 batch.updatedAt
454
+ function lastActiveAtOf(batch, lane) {
455
+ const evs = batch.events ?? [];
456
+ for (let i = evs.length - 1; i >= 0; i--) {
457
+ if (evs[i].lane === lane && evs[i].ts) return evs[i].ts;
458
+ }
459
+ return batch.updatedAt;
460
+ }
461
+ // produced(已产出产物清单):复用 gate 语义(gates.js fileExistsNonEmpty 同款判定:目录或 size>0)——
462
+ // 契约字段 exec→outputs / plan·audit→produce,逐项探测 artifactsDirOf 下解析路径存在且非空;
463
+ // 产出清单 = 契约中已存在的相对路径数组(绝对路径契约按 resolveArtifact 语义直接探测)
464
+ function producedOf(sessionId, batchId, batch, lane) {
465
+ const t = findTask(batch, lane);
466
+ if (!t) return [];
467
+ const field = t.layer === 'exec' ? 'outputs' : (t.layer === 'plan' || t.layer === 'audit' ? 'produce' : null);
468
+ if (!field || !Array.isArray(t[field])) return [];
469
+ const artifactsDir = artifactsDirOf(sessionId, batchId);
470
+ const existsNonEmpty = (p) => {
471
+ try {
472
+ const st = fs.statSync(isAbsPath(p) ? p : path.join(artifactsDir, p));
473
+ return st.isDirectory() || st.size > 0;
474
+ } catch { return false; }
475
+ };
476
+ return t[field].filter(existsNonEmpty);
477
+ }
478
+
479
+ function recoverBatches() {
480
+ const recovered = [];
481
+ const corrupt = [];
482
+ for (const sessionId of listSessions()) {
483
+ for (const batchId of listBatches(sessionId)) {
484
+ const batch = readBatch(sessionId, batchId);
485
+ if (!batch) {
486
+ // 损坏批次隔离(②,P0):登记已由 readBatch 幂等完成(first 才 warn);汇总 corrupt 后跳过,不击穿循环(INV-1)
487
+ if (isCorrupt(sessionId, batchId)) corrupt.push(sessionId + '/' + batchId);
488
+ continue;
489
+ }
490
+ if (schema.isBatchTerminal(batch.phase)) continue;
491
+ const recoveredLanes = [];
492
+ const detail = [];
493
+ for (const [lane, state] of Object.entries(batch.lanes)) {
494
+ if (state === 'running' || state === 'review') {
495
+ batch.lanes[lane] = 'idle';
496
+ recoveredLanes.push(lane);
497
+ // 审计详情:from 原态 / lastActiveAt 反查 / produced 复用 gate 语义(置 idle 前采集,只读不改产物)
498
+ detail.push({ lane, from: state, lastActiveAt: lastActiveAtOf(batch, lane), produced: producedOf(sessionId, batchId, batch, lane) });
499
+ }
500
+ }
501
+ if (recoveredLanes.length > 0) {
502
+ // 保留 recoveredLanes(向后兼容,既有测试断言其存在),新增 detail 审计详情数组
503
+ batch.events.push(newEvent(EVT.EVT_SYSTEM_RECOVERED, { batchId, sessionId, recoveredLanes, detail }));
504
+ batch.updatedAt = new Date().toISOString();
505
+ atomicWrite(batchFile(sessionId, batchId), batch);
506
+ recovered.push(sessionId + '/' + batchId);
507
+ }
508
+ }
509
+ }
510
+ // 向后兼容:返回数组形态(.length/.join 兼容 index.js:86-87 既有消费与 resume.js:60 原样透传);
511
+ // corrupt 汇总以非破坏性属性暴露(设计「{ recovered, corrupt }」语义经 recovered.corrupt 获得)
512
+ recovered.corrupt = corrupt;
513
+ if (corrupt.length) {
514
+ log.warn?.('[dsh-punky-swarm] recovery skipped ' + corrupt.length + ' corrupt batch(es): ' + corrupt.join(', '));
515
+ }
516
+ return recovered;
517
+ }
518
+
519
+ // 孤儿 worker 显式回收(③,P1,设计 D-002/D-003):lane.stalled 处置扩展——只标记 → 可显式回收。
520
+ // 语义:管理命令(显式触发,仿 recoverBatches 直写先例,不经棘轮表/不放宽 MEMBER_TRANSITIONS,C-5);
521
+ // 默认不自动处置(人审保留,D-003);lane.stalled 仍非成员状态(不新增成员态,W7 语义保持)。
522
+ // 前置校验:批次不存在/损坏 → throw;lane 非 running → throw(防双回收/误回收,INV-4:终态 lane 永不回收)。
523
+ // 事件:lane.recycled { lane, from:'running', reason:'stalled', note } 留痕;回收后走既有 member_status idle→running 重派(①)。
524
+ function recycleStalledLane(sessionId, batchId, lane) {
525
+ const batch = readBatch(sessionId, batchId);
526
+ if (!batch) throw new Error('batch not found: ' + batchId);
527
+ if (!(lane in batch.lanes)) throw new Error('unknown lane: ' + lane);
528
+ if (batch.lanes[lane] !== 'running') throw new Error('lane not running: ' + lane + ' (state=' + batch.lanes[lane] + ')');
529
+ // 校验该 lane 存在 lane.stalled 事件(batch.events 反查;stalled 只标记,回收须有停滞证据)
530
+ const hasStalled = (batch.events ?? []).some((e) => e.type === EVT.EVT_LANE_STALLED && e.lane === lane);
531
+ if (!hasStalled) throw new Error('no lane.stalled event for lane: ' + lane + ' (recycle requires stalled evidence)');
532
+ batch.lanes[lane] = 'idle';
533
+ batch.events.push(newEvent(EVT.EVT_LANE_RECYCLED, { lane, from: 'running', reason: 'stalled', note: null }));
534
+ batch.updatedAt = new Date().toISOString();
535
+ atomicWrite(batchFile(sessionId, batchId), batch);
536
+ return { ok: true, lane, from: 'running', to: 'idle' };
537
+ }
538
+
539
+ function listAllBatches() {
540
+ const all = [];
541
+ for (const sessionId of listSessions()) {
542
+ for (const batchId of listBatches(sessionId)) all.push({ sessionId, batchId });
543
+ }
544
+ return all;
545
+ }
546
+
547
+ // ---- 会话级治理状态(governance.json v2,design task-difficulty-gate §4)----
548
+ // 每回合任务难度评估(A/B/C)的事实源:lastAssign + history 审计 + 执行型工具计数 + pendingBatch
549
+ const GOV_SCHEMA = 2;
550
+ function govDefaults() {
551
+ return { schema: GOV_SCHEMA, execToolCount: 0, pendingBatch: false, pendingSince: null, lastAssign: null, history: [] };
552
+ }
553
+ function governanceFile(sessionId) {
554
+ if (!SESSION_RE.test(sessionId)) throw new Error('invalid sessionId: ' + sessionId);
555
+ return path.join(sessionDir(sessionId), 'governance.json');
556
+ }
557
+ // 无文件/损坏 → 返回默认(调用方无需判空)
558
+ function readGovernance(sessionId) {
559
+ const file = governanceFile(sessionId);
560
+ if (!fs.existsSync(file)) return govDefaults();
561
+ try { return { ...govDefaults(), ...JSON.parse(fs.readFileSync(file, 'utf8')) }; }
562
+ catch { return govDefaults(); }
563
+ }
564
+ // 原子写:读当前(或默认)→ 合并 patch → 落盘
565
+ function writeGovernance(sessionId, patch) {
566
+ const next = { ...readGovernance(sessionId), ...patch };
567
+ atomicWrite(governanceFile(sessionId), next);
568
+ return next;
569
+ }
570
+ // 执行型工具调用计数:execToolCount 累计 + lastAssign.execCallsSince 递增(评估后窗口内调用数)
571
+ function bumpExecCount(sessionId) {
572
+ const g = readGovernance(sessionId);
573
+ g.execToolCount = (g.execToolCount ?? 0) + 1;
574
+ if (g.lastAssign) g.lastAssign.execCallsSince = (g.lastAssign.execCallsSince ?? 0) + 1;
575
+ atomicWrite(governanceFile(sessionId), g);
576
+ return g;
577
+ }
578
+ // 评估过期判定:从未评估 / execCallsSince≥maxCalls(20) / 距 lastAssign.at≥maxAgeMs(30min)
579
+ function stale(sessionId, { maxCalls = 20, maxAgeMs = 30 * 60 * 1000 } = {}) {
580
+ const g = readGovernance(sessionId);
581
+ if (!g.lastAssign) return true;
582
+ if ((g.lastAssign.execCallsSince ?? 0) >= maxCalls) return true;
583
+ const at = Date.parse(g.lastAssign.at);
584
+ if (!Number.isFinite(at)) return true; // 时间戳非法按过期处理(宁严勿松)
585
+ return Date.now() - at >= maxAgeMs;
586
+ }
587
+ // 会话内是否有活跃(非终态)批次:pendingBatch 语义基于此(建批后清锁、批次终态后旧锁失效)
588
+ function hasActiveBatch(sessionId) {
589
+ return listBatches(sessionId).some((id) => {
590
+ const b = readBatch(sessionId, id);
591
+ return b && !schema.isBatchTerminal(b.phase);
592
+ });
593
+ }
594
+
595
+ return {
596
+ createBatch, readBatch, readBatchResult, isCorrupt, listBatches, listSessions, listAllBatches,
597
+ setMember, setPhase, appendEvent, claimAsset,
598
+ readChains, updateChains, updateLaneProgress, // P1-02:laneProgress 断点指针持久化
599
+ batchSettled, batchAutoReleaseable,
600
+ recoverBatches, recycleStalledLane, migrateLegacy, batchFile, sessionsDir, artifactsDirOf, gateStatus: gates.gateStatus,
601
+ readGovernance, writeGovernance, bumpExecCount, stale, hasActiveBatch, governanceFile,
602
+ // 损坏旁路清单(②):幂等登记 / 只读清单 / 人工修复后清除标记(透传 corrupt-registry)
603
+ corruptRegistry: { markBatchCorrupt: corruptRegistry.markBatchCorrupt, listCorruptBatches: corruptRegistry.listCorruptBatches, clearCorruptMark: corruptRegistry.clearCorruptMark, corruptFileOf: corruptRegistry.corruptFileOf },
604
+ // 归档只读/幂等面(batch_status 面板与审计查询用)
605
+ archive: { archiveBatch: archive.archiveBatch, readManifest: archive.readManifest, listArchived: archive.listArchived },
606
+ };
607
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ Copyright (C) 2025-2026 Punky
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ // lib/state/task-utils.js —— 任务定位纯函数单点(P1-04 收敛,替代 5 处同构遍历)
19
+ // 零依赖设计:不 import 任何模块,杜绝循环依赖(原各文件自持实现的根因:
20
+ // gates.js「避免 machine→gates 依赖」/ machine.js「自持只读实现」等注释)。
21
+ // 防御取最全形态:batch?.wavePlan ?? [] + w.tasks ?? [](容缺省与空数组),
22
+ // null 元素 / 非数组 tasks 跳过,找到即返回(等价 resume 版提前 break)。
23
+ // 消费方:gates.js / machine.js / store.js / lane-heartbeat.js / resume.js(见各文件 import)。
24
+
25
+ /**
26
+ * 在 wavePlan 中按 lane id 定位任务(语义:遍历 wavePlan[].tasks 按 t.id === lane 匹配)。
27
+ * @param {object|null} batch 批次对象(可能为 null / 缺 wavePlan)
28
+ * @param {string} lane lane 任务 ID
29
+ * @returns {object|null} 命中返回任务对象;未命中 / 输入不完整返回 null
30
+ */
31
+ export function findTask(batch, lane) {
32
+ if (!batch) return null;
33
+ for (const w of batch.wavePlan ?? []) {
34
+ if (!w || !Array.isArray(w.tasks)) continue;
35
+ for (const t of w.tasks) {
36
+ if (t && t.id === lane) return t; // 找到即返回(提前返回语义,等价 resume 版 break)
37
+ }
38
+ }
39
+ return null;
40
+ }