scrum-skills 0.2.2 → 0.2.4
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/.agents/skills/scrum-planning/references/schema.md +3 -2
- package/README.md +49 -8
- package/assets/board/200.html +1 -1
- package/assets/board/404.html +1 -1
- package/assets/board/_nuxt/0VBLkWnu.js +1 -0
- package/assets/board/_nuxt/3G_ZLrk2.js +1 -0
- package/assets/board/_nuxt/51OWHj8c.js +2 -0
- package/assets/board/_nuxt/5qqsfNvn.js +1 -0
- package/assets/board/_nuxt/8QIcohze.js +1 -0
- package/assets/board/_nuxt/B0YDpoC7.js +1 -0
- package/assets/board/_nuxt/B3oUyqcV.js +1 -0
- package/assets/board/_nuxt/B43-KCi6.js +1 -0
- package/assets/board/_nuxt/B5oiGeHN.js +1 -0
- package/assets/board/_nuxt/BGssl5Ao.js +1 -0
- package/assets/board/_nuxt/BJW3UkS7.js +1 -0
- package/assets/board/_nuxt/BJcBC4jN.js +1 -0
- package/assets/board/_nuxt/BNKWWG8o.js +1 -0
- package/assets/board/_nuxt/BPTrRhcw.js +1 -0
- package/assets/board/_nuxt/BSov4ehT.js +1 -0
- package/assets/board/_nuxt/BTxVIV74.js +1 -0
- package/assets/board/_nuxt/B_SlFcWp.js +1 -0
- package/assets/board/_nuxt/Baq-Hrpf.js +1 -0
- package/assets/board/_nuxt/Bb3Limos.js +1 -0
- package/assets/board/_nuxt/Bh7eUS6e.js +1 -0
- package/assets/board/_nuxt/Bhg7noz0.js +1 -0
- package/assets/board/_nuxt/Bu60uYgs.js +1 -0
- package/assets/board/_nuxt/BuBpFHLu.js +2 -0
- package/assets/board/_nuxt/BuOKDFiJ.js +1 -0
- package/assets/board/_nuxt/BuuAjxsV.js +1 -0
- package/assets/board/_nuxt/C-GYjxwr.js +13 -0
- package/assets/board/_nuxt/C6wyeu66.js +1 -0
- package/assets/board/_nuxt/C9MiPNdJ.js +1 -0
- package/assets/board/_nuxt/C9StZL3D.js +3 -0
- package/assets/board/_nuxt/CD0uJuyA.js +1 -0
- package/assets/board/_nuxt/CDL4GvLz.js +1 -0
- package/assets/board/_nuxt/CHZ_IHOa.js +1 -0
- package/assets/board/_nuxt/CK9_veoz.js +1 -0
- package/assets/board/_nuxt/CNbS93H9.js +1 -0
- package/assets/board/_nuxt/CQTvWh4i.js +1 -0
- package/assets/board/_nuxt/CUnX_QKi.js +1 -0
- package/assets/board/_nuxt/C_uy5_kh.js +1 -0
- package/assets/board/_nuxt/CeiBZIn1.js +95 -0
- package/assets/board/_nuxt/Cfk_R787.js +1 -0
- package/assets/board/_nuxt/ChJuu-Ky.js +1 -0
- package/assets/board/_nuxt/CjRYYbsC.js +1 -0
- package/assets/board/_nuxt/Ck3md8pL.js +1 -0
- package/assets/board/_nuxt/CkDrD28D.js +1 -0
- package/assets/board/_nuxt/CmvybFI5.js +1 -0
- package/assets/board/_nuxt/CoBNWjS2.js +1 -0
- package/assets/board/_nuxt/CoPhTDCT.js +1 -0
- package/assets/board/_nuxt/Cq4Q0czX.js +1 -0
- package/assets/board/_nuxt/CsyY7ot8.js +1 -0
- package/assets/board/_nuxt/CuJUHLl8.js +1 -0
- package/assets/board/_nuxt/Cu_-fa7D.js +1 -0
- package/assets/board/_nuxt/CvFE1CeN.js +1 -0
- package/assets/board/_nuxt/CyAQx5wv.js +1 -0
- package/assets/board/_nuxt/D13eMNHe.js +1 -0
- package/assets/board/_nuxt/D1Yxjnz9.js +1 -0
- package/assets/board/_nuxt/D1_aWe_t.js +1 -0
- package/assets/board/_nuxt/D3YCAmfu.js +1 -0
- package/assets/board/_nuxt/D8Bpf7sE.js +1 -0
- package/assets/board/_nuxt/DHihWhf5.js +1 -0
- package/assets/board/_nuxt/DJUwhJ8T.js +1 -0
- package/assets/board/_nuxt/DLkAKotw.js +1 -0
- package/assets/board/_nuxt/DSaWoFHS.js +1 -0
- package/assets/board/_nuxt/DZo3dYLJ.js +3 -0
- package/assets/board/_nuxt/DaqxYsbV.js +1 -0
- package/assets/board/_nuxt/DdjjthKs.js +1 -0
- package/assets/board/_nuxt/DhhR1iR4.js +1 -0
- package/assets/board/_nuxt/DmKZi-Wo.js +1 -0
- package/assets/board/_nuxt/DpXJ_tCU.js +1 -0
- package/assets/board/_nuxt/DrbU50Zd.js +9 -0
- package/assets/board/_nuxt/DsF2J4jZ.js +1 -0
- package/assets/board/_nuxt/DsNkyZPc.js +1 -0
- package/assets/board/_nuxt/DtVfI_h9.js +1 -0
- package/assets/board/_nuxt/DwcwzwSx.js +1 -0
- package/assets/board/_nuxt/Dwd1XRDX.js +1 -0
- package/assets/board/_nuxt/HclZy1k7.js +1 -0
- package/assets/board/_nuxt/HrhQecsY.js +6 -0
- package/assets/board/_nuxt/HvSJjbUd.js +1 -0
- package/assets/board/_nuxt/KhBLkIKl.js +1 -0
- package/assets/board/_nuxt/MTucdeiW.js +1 -0
- package/assets/board/_nuxt/Oe3jsM_c.js +1 -0
- package/assets/board/_nuxt/RJtjdZeG.js +1 -0
- package/assets/board/_nuxt/RM4VQc4e.js +1 -0
- package/assets/board/_nuxt/Vo4wMbnS.js +1 -0
- package/assets/board/_nuxt/ZuoAH0hh.js +1 -0
- package/assets/board/_nuxt/_id_.B2NDKSlX.css +1 -0
- package/assets/board/_nuxt/builds/latest.json +1 -1
- package/assets/board/_nuxt/builds/meta/ecbff4a6-6538-49b0-9844-64f03ebd103a.json +1 -0
- package/assets/board/_nuxt/burndown.BCLMmigw.css +1 -0
- package/assets/board/_nuxt/entry.nk2xQ-sa.css +2 -0
- package/assets/board/_nuxt/epics.B4-eK1Py.css +1 -0
- package/assets/board/_nuxt/fFGyRlCx.js +1 -0
- package/assets/board/_nuxt/gantt.DATOqoZf.css +1 -0
- package/assets/board/_nuxt/l05SqxhY.js +1 -0
- package/assets/board/_nuxt/milestones.z-UuT44X.css +1 -0
- package/assets/board/_nuxt/oXSDGTSr.js +1 -0
- package/assets/board/_nuxt/obNw9aRV.js +3 -0
- package/assets/board/_nuxt/pages.Bi6nErnd.css +1 -0
- package/assets/board/_nuxt/u88eXCXy.js +1 -0
- package/assets/board/_nuxt/uGmbQYdY.js +1 -0
- package/assets/board/burndown/index.html +1 -1
- package/assets/board/epics/index.html +1 -1
- package/assets/board/gantt/index.html +1 -1
- package/assets/board/index.html +1 -1
- package/assets/board/milestones/index.html +1 -1
- package/bin/scrum-skills.mjs +34 -11
- package/package.json +40 -41
- package/templates/AGENTS.md +1 -1
- package/tools/agile-tools.mjs +504 -0
- package/tools/ai.mjs +464 -0
- package/tools/projects.mjs +140 -0
- package/tools/serve.mjs +60 -17
- package/tools/validate.mjs +21 -7
- package/assets/board/_nuxt/4lorSwrA.js +0 -1
- package/assets/board/_nuxt/7WA2NiBu.js +0 -1
- package/assets/board/_nuxt/BMkghBSN.js +0 -6
- package/assets/board/_nuxt/BO7jzDkR.js +0 -1
- package/assets/board/_nuxt/BZ3zxHIj.js +0 -1
- package/assets/board/_nuxt/BhF-44v-.js +0 -13
- package/assets/board/_nuxt/Brv_Bdja.js +0 -3
- package/assets/board/_nuxt/C3UUYAQd.js +0 -1
- package/assets/board/_nuxt/CJAcsUQO.js +0 -3
- package/assets/board/_nuxt/CNsYxQaQ.js +0 -1
- package/assets/board/_nuxt/CRaaiMq0.js +0 -1
- package/assets/board/_nuxt/CW444i7s.js +0 -1
- package/assets/board/_nuxt/CfONUjDo.js +0 -1
- package/assets/board/_nuxt/DKNZ_ohS.js +0 -1
- package/assets/board/_nuxt/DuWpPgcw.js +0 -3
- package/assets/board/_nuxt/LjrS__tx.js +0 -1
- package/assets/board/_nuxt/_id_.DhbZaLbc.css +0 -1
- package/assets/board/_nuxt/builds/meta/460252c3-0ea6-40d0-8f13-d20a01a88ad5.json +0 -1
- package/assets/board/_nuxt/burndown.BENzf335.css +0 -1
- package/assets/board/_nuxt/entry.CRMqPpqu.css +0 -2
- package/assets/board/_nuxt/epics.BeC881O2.css +0 -1
- package/assets/board/_nuxt/gantt.B1Ztrpq6.css +0 -1
- package/assets/board/_nuxt/laFvd4Y3.js +0 -16
- package/assets/board/_nuxt/milestones.B2QTaR0Y.css +0 -1
- package/assets/board/_nuxt/pages.DuMz_V7L.css +0 -1
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 敏捷数据工具(零依赖,node >= 18)
|
|
3
|
+
*
|
|
4
|
+
* AI 助手手上的两件家伙事:检索当前项目的敏捷数据、按路径打补丁改它。
|
|
5
|
+
* 这是全项目唯一「机器自动写业务数据」的入口,所以三层护栏缺一不可:
|
|
6
|
+
* 1. 只认 agile/backlog.json | sprints.json | journal.json 三个文件,别的路径一律拒;
|
|
7
|
+
* 2. 写完立刻跑 tools/validate.mjs,校验不过就把改前内容原样写回去;
|
|
8
|
+
* 3. 手动审核策略下根本不落盘,只产待审变更(propose / applyPending / rejectPending)。
|
|
9
|
+
*
|
|
10
|
+
* 补丁路径用「点号 + id」写法(不需要记下标):
|
|
11
|
+
* stories.S-011.status 对象字段
|
|
12
|
+
* stories.S-011.tasks 数组本身(配 op: push / remove)
|
|
13
|
+
* stories.S-011.tasks.2.title 数组第 2 项(纯数字段当下标)
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 写回是「按文本打补丁」,不是「整份重新 stringify」。
|
|
16
|
+
* 各家项目的数据文件排版不一样(有人一条记录一行、有人内联数组、有人 CRLF),
|
|
17
|
+
* 一旦整份重排,改一个状态就会铺出几千行 diff,合并冲突满天飞。
|
|
18
|
+
* 这里只替换被补丁命中的那段字符,其余字节一个不动。
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const validator = fileURLToPath(new URL('./validate.mjs', import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** 允许 AI 写回去的文件白名单——路径拼接只从这里来,杜绝越界 */
|
|
28
|
+
export const AGILE_FILES = ['backlog', 'sprints', 'journal'];
|
|
29
|
+
|
|
30
|
+
/** 默认校验器:直接调本仓库的 validate.mjs,与 serve 启动前的口径完全一致 */
|
|
31
|
+
export function runValidate(root) {
|
|
32
|
+
try {
|
|
33
|
+
execFileSync(process.execPath, [validator, root], { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
throw new Error(`写入后校验失败:${(e.stdout || e.message || '').toString().split('\n').filter(Boolean).slice(-3).join(' ')}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 解析补丁路径 → { parent, key },路径走不通时把「走不通的那一段」报出来 */
|
|
40
|
+
function resolvePath(doc, path) {
|
|
41
|
+
const segments = String(path ?? '').split('.').filter(Boolean);
|
|
42
|
+
if (!segments.length) throw new Error('补丁路径不能为空');
|
|
43
|
+
let node = doc;
|
|
44
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
45
|
+
const seg = segments[i];
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
if (/^\d+$/.test(seg)) {
|
|
48
|
+
if (Number(seg) >= node.length) throw new Error(`路径 ${path} 里的下标 ${seg} 越界`);
|
|
49
|
+
node = node[Number(seg)];
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const hit = node.find(item => item && item.id === seg);
|
|
53
|
+
if (!hit) throw new Error(`路径 ${path} 里的 ${seg} 不存在`);
|
|
54
|
+
node = hit;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (node && typeof node === 'object' && seg in node) {
|
|
58
|
+
node = node[seg];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`路径 ${path} 走不通:${seg} 之上没有这个字段`);
|
|
62
|
+
}
|
|
63
|
+
return { parent: node, key: segments[segments.length - 1] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// 文本级补丁:带位置的 JSON 解析 + 只改动命中的那一段字符
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
const WS = /[ \t\r\n]/;
|
|
71
|
+
const PRIMITIVE_END = /[\s,\]}]/;
|
|
72
|
+
|
|
73
|
+
/** 递归下降解析,给每个值记下它在原文里的字符区间(end 为后一位,左闭右开) */
|
|
74
|
+
function parseTree(text) {
|
|
75
|
+
let i = 0;
|
|
76
|
+
const skip = () => {
|
|
77
|
+
while (i < text.length && WS.test(text[i])) i++;
|
|
78
|
+
};
|
|
79
|
+
function parseString() {
|
|
80
|
+
const start = i;
|
|
81
|
+
i += 1; // 起始引号
|
|
82
|
+
while (i < text.length) {
|
|
83
|
+
const ch = text[i];
|
|
84
|
+
if (ch === '\\') {
|
|
85
|
+
i += 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
i += 1;
|
|
89
|
+
if (ch === '"') break;
|
|
90
|
+
}
|
|
91
|
+
return { type: 'string', start, end: i, value: JSON.parse(text.slice(start, i)) };
|
|
92
|
+
}
|
|
93
|
+
function parseObject() {
|
|
94
|
+
const start = i;
|
|
95
|
+
i += 1; // {
|
|
96
|
+
const entries = [];
|
|
97
|
+
skip();
|
|
98
|
+
if (text[i] === '}') {
|
|
99
|
+
i += 1;
|
|
100
|
+
return { type: 'object', start, end: i, entries };
|
|
101
|
+
}
|
|
102
|
+
while (true) {
|
|
103
|
+
skip();
|
|
104
|
+
const key = parseString();
|
|
105
|
+
skip();
|
|
106
|
+
if (text[i] !== ':') throw new Error('JSON 结构异常:对象里缺少冒号');
|
|
107
|
+
i += 1;
|
|
108
|
+
const value = parseValue();
|
|
109
|
+
entries.push({ key: key.value, keyStart: key.start, value });
|
|
110
|
+
skip();
|
|
111
|
+
if (text[i] === ',') {
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (text[i] === '}') {
|
|
116
|
+
i += 1;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
throw new Error('JSON 结构异常:对象没有闭合');
|
|
120
|
+
}
|
|
121
|
+
return { type: 'object', start, end: i, entries };
|
|
122
|
+
}
|
|
123
|
+
function parseArray() {
|
|
124
|
+
const start = i;
|
|
125
|
+
i += 1; // [
|
|
126
|
+
const items = [];
|
|
127
|
+
skip();
|
|
128
|
+
if (text[i] === ']') {
|
|
129
|
+
i += 1;
|
|
130
|
+
return { type: 'array', start, end: i, items };
|
|
131
|
+
}
|
|
132
|
+
while (true) {
|
|
133
|
+
items.push(parseValue());
|
|
134
|
+
skip();
|
|
135
|
+
if (text[i] === ',') {
|
|
136
|
+
i += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (text[i] === ']') {
|
|
140
|
+
i += 1;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
throw new Error('JSON 结构异常:数组没有闭合');
|
|
144
|
+
}
|
|
145
|
+
return { type: 'array', start, end: i, items };
|
|
146
|
+
}
|
|
147
|
+
function parseValue() {
|
|
148
|
+
skip();
|
|
149
|
+
const ch = text[i];
|
|
150
|
+
if (ch === '{') return parseObject();
|
|
151
|
+
if (ch === '[') return parseArray();
|
|
152
|
+
if (ch === '"') return parseString();
|
|
153
|
+
const start = i;
|
|
154
|
+
while (i < text.length && !PRIMITIVE_END.test(text[i])) i += 1;
|
|
155
|
+
return { type: 'literal', start, end: i, value: JSON.parse(text.slice(start, i)) };
|
|
156
|
+
}
|
|
157
|
+
return parseValue();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 按路径段找值节点:对象按字段名,数组按数字下标或记录 id */
|
|
161
|
+
function nodeAt(root, segments) {
|
|
162
|
+
let node = root;
|
|
163
|
+
for (const seg of segments) {
|
|
164
|
+
if (!node) return null;
|
|
165
|
+
if (node.type === 'object') {
|
|
166
|
+
const entry = node.entries.find(e => e.key === seg);
|
|
167
|
+
node = entry ? entry.value : null;
|
|
168
|
+
} else if (node.type === 'array') {
|
|
169
|
+
if (/^\d+$/.test(seg)) {
|
|
170
|
+
node = node.items[Number(seg)] ?? null;
|
|
171
|
+
} else {
|
|
172
|
+
const hit = node.items.find(item => item.type === 'object'
|
|
173
|
+
&& item.entries.some(e => e.key === 'id' && e.value.value === seg));
|
|
174
|
+
node = hit ?? null;
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return node;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isInline = (text, node) => !text.slice(node.start, node.end).includes('\n');
|
|
184
|
+
|
|
185
|
+
/** 单行紧凑写法:{ "a": 1, "b": [1, 2] } —— 对齐「一条记录一行」的项目习惯 */
|
|
186
|
+
function inlineRender(value) {
|
|
187
|
+
if (Array.isArray(value)) return `[${value.map(inlineRender).join(', ')}]`;
|
|
188
|
+
if (value && typeof value === 'object') {
|
|
189
|
+
const body = Object.entries(value).map(([k, v]) => `${JSON.stringify(k)}: ${inlineRender(v)}`);
|
|
190
|
+
return body.length ? `{ ${body.join(', ')} }` : '{}';
|
|
191
|
+
}
|
|
192
|
+
return JSON.stringify(value) ?? 'null';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function prettyRender(value, indent, unit) {
|
|
196
|
+
const body = JSON.stringify(value, null, unit);
|
|
197
|
+
if (!body.includes('\n')) return body;
|
|
198
|
+
return body.split('\n').map((line, idx) => (idx === 0 ? line : indent + line)).join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 某一行开头的空白:用值自己的起始位置反查该行缩进 */
|
|
202
|
+
function lineIndentAt(text, pos) {
|
|
203
|
+
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
|
204
|
+
return text.slice(lineStart, pos).match(/^[ \t]*/)?.[0] ?? '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 缩进单位:取文件里第一处缩进;取不到就 2 空格 */
|
|
208
|
+
function indentUnit(text) {
|
|
209
|
+
return text.match(/\n([ \t]+)\S/)?.[1] ?? ' ';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const newlineOf = text => (text.includes('\r\n') ? '\r\n' : '\n');
|
|
213
|
+
|
|
214
|
+
/** 让新内容跟原来的长相一致:原来一行就写一行,原来多行就按同缩进展开 */
|
|
215
|
+
function renderValue(text, node, value) {
|
|
216
|
+
if (isInline(text, node)) return inlineRender(value);
|
|
217
|
+
return prettyRender(value, lineIndentAt(text, node.start), indentUnit(text));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 往对象里插一个新字段(原来没有这个 key),缩进与长相都照兄弟字段 */
|
|
221
|
+
function insertMember(text, obj, key, value) {
|
|
222
|
+
const unit = indentUnit(text);
|
|
223
|
+
const nl = newlineOf(text);
|
|
224
|
+
const last = obj.entries[obj.entries.length - 1];
|
|
225
|
+
if (!last) {
|
|
226
|
+
if (!isInline(text, obj)) {
|
|
227
|
+
const indent = lineIndentAt(text, obj.start);
|
|
228
|
+
return `${text.slice(0, obj.start)}{${nl}${indent}${unit}"${key}": ${inlineRender(value)}${nl}${indent}}${text.slice(obj.end)}`;
|
|
229
|
+
}
|
|
230
|
+
return `${text.slice(0, obj.start)}{ "${key}": ${inlineRender(value)} }${text.slice(obj.end)}`;
|
|
231
|
+
}
|
|
232
|
+
const memberIndent = lineIndentAt(text, last.keyStart);
|
|
233
|
+
const member = isInline(text, last.value)
|
|
234
|
+
? `"${key}": ${inlineRender(value)}`
|
|
235
|
+
: `"${key}": ${prettyRender(value, memberIndent, unit)}`;
|
|
236
|
+
const at = last.value.end;
|
|
237
|
+
return `${text.slice(0, at)},${nl}${memberIndent}${member}${text.slice(at)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 往数组尾部追加一项,长相跟最后一项保持一致 */
|
|
241
|
+
function insertItem(text, arr, value) {
|
|
242
|
+
const unit = indentUnit(text);
|
|
243
|
+
const nl = newlineOf(text);
|
|
244
|
+
const last = arr.items[arr.items.length - 1];
|
|
245
|
+
if (!last) {
|
|
246
|
+
if (!isInline(text, arr)) {
|
|
247
|
+
const indent = lineIndentAt(text, arr.start);
|
|
248
|
+
return `${text.slice(0, arr.start)}[${nl}${indent}${unit}${inlineRender(value)}${nl}${indent}]${text.slice(arr.end)}`;
|
|
249
|
+
}
|
|
250
|
+
return `${text.slice(0, arr.start)}[${inlineRender(value)}]${text.slice(arr.end)}`;
|
|
251
|
+
}
|
|
252
|
+
if (isInline(text, arr)) {
|
|
253
|
+
return `${text.slice(0, last.end)}, ${inlineRender(value)}${text.slice(last.end)}`;
|
|
254
|
+
}
|
|
255
|
+
const itemIndent = lineIndentAt(text, last.start);
|
|
256
|
+
const item = isInline(text, last) ? inlineRender(value) : prettyRender(value, itemIndent, unit);
|
|
257
|
+
return `${text.slice(0, last.end)},${nl}${itemIndent}${item}${text.slice(last.end)}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 删数组第 idx 项:连同它前面(或后面)那个逗号一起删 */
|
|
261
|
+
function removeItem(text, arr, idx) {
|
|
262
|
+
const items = arr.items;
|
|
263
|
+
if (items.length === 1) return `${text.slice(0, arr.start)}[]${text.slice(arr.end)}`;
|
|
264
|
+
if (idx > 0) return text.slice(0, items[idx - 1].end) + text.slice(items[idx].end);
|
|
265
|
+
return text.slice(0, items[0].start) + text.slice(items[1].start);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 删对象里的一个字段:原则同 removeItem */
|
|
269
|
+
function removeMember(text, obj, entry) {
|
|
270
|
+
const idx = obj.entries.indexOf(entry);
|
|
271
|
+
if (obj.entries.length === 1) return `${text.slice(0, obj.start)}{}${text.slice(obj.end)}`;
|
|
272
|
+
if (idx > 0) return text.slice(0, obj.entries[idx - 1].value.end) + text.slice(entry.value.end);
|
|
273
|
+
return text.slice(0, entry.keyStart) + text.slice(obj.entries[1].keyStart);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 把一条补丁打到原文上,返回新文本。
|
|
278
|
+
* 语义与 applyOne(改的是解析后的对象)一一对应,区别是这里只动命中的那几个字符。
|
|
279
|
+
*/
|
|
280
|
+
function patchText(text, patch) {
|
|
281
|
+
const op = patch?.op ?? 'set';
|
|
282
|
+
const segments = String(patch?.path ?? '').split('.').filter(Boolean);
|
|
283
|
+
if (!segments.length) throw new Error('补丁路径不能为空');
|
|
284
|
+
const root = parseTree(text);
|
|
285
|
+
|
|
286
|
+
if (op === 'push') {
|
|
287
|
+
const arr = nodeAt(root, segments);
|
|
288
|
+
if (!arr || arr.type !== 'array') throw new Error(`路径 ${patch.path} 不是数组,不能 push`);
|
|
289
|
+
return insertItem(text, arr, patch.value);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const parent = nodeAt(root, segments.slice(0, -1));
|
|
293
|
+
const key = segments[segments.length - 1];
|
|
294
|
+
if (!parent) throw new Error(`路径 ${patch.path} 走不通`);
|
|
295
|
+
const isIndex = /^\d+$/.test(key);
|
|
296
|
+
|
|
297
|
+
if (op === 'set') {
|
|
298
|
+
if (parent.type === 'object') {
|
|
299
|
+
const entry = parent.entries.find(e => e.key === key);
|
|
300
|
+
if (!entry) return insertMember(text, parent, key, patch.value);
|
|
301
|
+
return text.slice(0, entry.value.start) + renderValue(text, entry.value, patch.value) + text.slice(entry.value.end);
|
|
302
|
+
}
|
|
303
|
+
if (parent.type === 'array') {
|
|
304
|
+
const item = isIndex ? parent.items[Number(key)] : null;
|
|
305
|
+
if (!item) throw new Error(`路径 ${patch.path} 在数组里找不到 ${key} 这一项`);
|
|
306
|
+
return text.slice(0, item.start) + renderValue(text, item, patch.value) + text.slice(item.end);
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`路径 ${patch.path} 的父级既不是对象也不是数组`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (op === 'remove') {
|
|
312
|
+
if (parent.type === 'array') {
|
|
313
|
+
const item = isIndex ? parent.items[Number(key)] : null;
|
|
314
|
+
if (!item) throw new Error(`路径 ${patch.path} 在数组里找不到 ${key} 这一项`);
|
|
315
|
+
return removeItem(text, parent, Number(key));
|
|
316
|
+
}
|
|
317
|
+
if (parent.type === 'object') {
|
|
318
|
+
const entry = parent.entries.find(e => e.key === key);
|
|
319
|
+
if (!entry) throw new Error(`路径 ${patch.path} 要删的字段不存在`);
|
|
320
|
+
return removeMember(text, parent, entry);
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`路径 ${patch.path} 的父级既不是对象也不是数组`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error(`不支持的补丁操作 ${op}(只认 set / push / remove)`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 把三个文件拆成「一条记录一行」的可检索单元,路径直接就是补丁能用的写法 */
|
|
329
|
+
function recordsOf(file, doc) {
|
|
330
|
+
const out = [];
|
|
331
|
+
const add = (path, item) => {
|
|
332
|
+
if (item && typeof item === 'object') out.push({ path, item });
|
|
333
|
+
};
|
|
334
|
+
if (file === 'backlog') {
|
|
335
|
+
(doc.epics ?? []).forEach(e => add(`epics.${e.id}`, e));
|
|
336
|
+
(doc.stories ?? []).forEach(s => {
|
|
337
|
+
add(`stories.${s.id}`, s);
|
|
338
|
+
(s.tasks ?? []).forEach(t => add(`stories.${s.id}.tasks.${t.id}`, t));
|
|
339
|
+
});
|
|
340
|
+
} else if (file === 'sprints') {
|
|
341
|
+
(doc.sprints ?? []).forEach(s => add(`sprints.${s.id}`, s));
|
|
342
|
+
(doc.milestones ?? []).forEach(m => add(`milestones.${m.id}`, m));
|
|
343
|
+
} else if (file === 'journal') {
|
|
344
|
+
(doc.journals ?? []).forEach(j => add(`journals.${j.taskId}`, j));
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function applyOne(doc, patch) {
|
|
350
|
+
const op = patch?.op ?? 'set';
|
|
351
|
+
const { parent, key } = resolvePath(doc, patch?.path);
|
|
352
|
+
const readBefore = () => (Array.isArray(parent) && /^\d+$/.test(key) ? parent[Number(key)] : parent[key]);
|
|
353
|
+
if (op === 'set') {
|
|
354
|
+
const before = readBefore();
|
|
355
|
+
if (Array.isArray(parent) && /^\d+$/.test(key)) parent[Number(key)] = patch.value;
|
|
356
|
+
else parent[key] = patch.value;
|
|
357
|
+
return { op, path: patch.path, before, after: patch.value };
|
|
358
|
+
}
|
|
359
|
+
if (op === 'push') {
|
|
360
|
+
const arr = readBefore();
|
|
361
|
+
if (!Array.isArray(arr)) throw new Error(`路径 ${patch.path} 不是数组,不能 push`);
|
|
362
|
+
const before = arr.length;
|
|
363
|
+
arr.push(patch.value);
|
|
364
|
+
return { op, path: patch.path, before, after: arr.length };
|
|
365
|
+
}
|
|
366
|
+
if (op === 'remove') {
|
|
367
|
+
const before = readBefore();
|
|
368
|
+
if (Array.isArray(parent) && /^\d+$/.test(key)) parent.splice(Number(key), 1);
|
|
369
|
+
else if (Array.isArray(parent)) throw new Error(`路径 ${patch.path} 要按下标删除(数组里没有 ${key})`);
|
|
370
|
+
else delete parent[key];
|
|
371
|
+
return { op, path: patch.path, before, after: undefined };
|
|
372
|
+
}
|
|
373
|
+
throw new Error(`不支持的补丁操作 ${op}(只认 set / push / remove)`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* root 可以是字符串,也可以是 () => string。
|
|
378
|
+
* 服务端要传函数:看板上换项目时「当前项目」会变,工具不能一直盯着启动时那个目录,
|
|
379
|
+
* 否则用户切到 B 项目、AI 却把补丁写进 A 项目。
|
|
380
|
+
*/
|
|
381
|
+
export function createAgileTools({ root, validate = runValidate } = {}) {
|
|
382
|
+
let seq = 0;
|
|
383
|
+
const pending = new Map();
|
|
384
|
+
const currentRoot = typeof root === 'function' ? root : () => root;
|
|
385
|
+
|
|
386
|
+
const fileOf = file => {
|
|
387
|
+
if (!AGILE_FILES.includes(file)) {
|
|
388
|
+
throw new Error(`只允许写 agile/ 下的三个文件(${AGILE_FILES.join(' / ')}.json),收到的是 ${file}`);
|
|
389
|
+
}
|
|
390
|
+
return join(currentRoot(), 'agile', `${file}.json`);
|
|
391
|
+
};
|
|
392
|
+
const read = file => JSON.parse(readFileSync(fileOf(file), 'utf8'));
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 写盘 + 校验 + 失败回滚:任何一步出错,文件都必须是改前那份。
|
|
396
|
+
* 写回走文本补丁(patchText):只替换命中的字符,排版 / 换行风格原样保留,
|
|
397
|
+
* 改一个状态就只差一行,不会把整份文件重排成合并冲突。
|
|
398
|
+
*/
|
|
399
|
+
function persist(file, patches) {
|
|
400
|
+
const target = fileOf(file);
|
|
401
|
+
const previous = readFileSync(target, 'utf8');
|
|
402
|
+
let next = previous;
|
|
403
|
+
for (const patch of patches) next = patchText(next, patch);
|
|
404
|
+
writeFileSync(target, next);
|
|
405
|
+
try {
|
|
406
|
+
validate(currentRoot());
|
|
407
|
+
} catch (e) {
|
|
408
|
+
writeFileSync(target, previous);
|
|
409
|
+
throw new Error(`校验未通过,已把 ${file}.json 回滚成改前内容:${e.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function applyAll(file, patches) {
|
|
414
|
+
const doc = read(file); // 先过文件白名单:非法文件要越过「补丁为空」先报出来
|
|
415
|
+
if (!Array.isArray(patches) || patches.length === 0) throw new Error('补丁不能为空(要改什么得说清楚)');
|
|
416
|
+
return { doc, changes: patches.map(p => applyOne(doc, p)) };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
read,
|
|
421
|
+
/**
|
|
422
|
+
* 记录级检索:agile 的 JSON 是缩进排版,两个关键词很少落在同一行,
|
|
423
|
+
* 所以按「一条记录」为单位匹配(story / task / sprint / milestone / journal),
|
|
424
|
+
* 命中的 path 可以直接拿来写 update 补丁,省掉模型自己数下标。
|
|
425
|
+
*/
|
|
426
|
+
search(query, { limit = 20, file } = {}) {
|
|
427
|
+
const terms = String(query ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
428
|
+
if (!terms.length) throw new Error('检索关键词不能为空');
|
|
429
|
+
const files = file ? [file] : AGILE_FILES;
|
|
430
|
+
const hits = [];
|
|
431
|
+
let truncated = false;
|
|
432
|
+
for (const name of files) {
|
|
433
|
+
const abs = fileOf(name);
|
|
434
|
+
if (!existsSync(abs)) continue;
|
|
435
|
+
const raw = readFileSync(abs, 'utf8');
|
|
436
|
+
let doc;
|
|
437
|
+
try {
|
|
438
|
+
doc = JSON.parse(raw);
|
|
439
|
+
} catch {
|
|
440
|
+
continue; // 半截文件不参与检索,交给 validate 去报
|
|
441
|
+
}
|
|
442
|
+
const lines = raw.split(/\r?\n/);
|
|
443
|
+
for (const { path, item } of recordsOf(name, doc)) {
|
|
444
|
+
const text = JSON.stringify(item);
|
|
445
|
+
const lower = text.toLowerCase();
|
|
446
|
+
if (!terms.every(t => lower.includes(t))) continue;
|
|
447
|
+
if (hits.length >= limit) {
|
|
448
|
+
truncated = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const anchor = item.id ?? item.taskId;
|
|
452
|
+
const line = (anchor ? lines.findIndex(l => l.includes(`"${anchor}"`)) : -1) + 1 || 1;
|
|
453
|
+
hits.push({ file: `agile/${name}.json`, path, line, text: text.slice(0, 400) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { query: String(query), terms, count: hits.length, truncated, hits };
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
/** 直接落盘(自动审核策略走这条) */
|
|
460
|
+
patch({ file, patches, reason } = {}) {
|
|
461
|
+
const { changes } = applyAll(file, patches);
|
|
462
|
+
persist(file, patches);
|
|
463
|
+
return { ok: true, file, reason: reason ?? '', validated: true, changes };
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
/** 只出待审变更,不落盘(手动审核策略走这条) */
|
|
467
|
+
propose({ file, patches, reason } = {}) {
|
|
468
|
+
const { doc, changes } = applyAll(file, patches);
|
|
469
|
+
void doc;
|
|
470
|
+
const record = {
|
|
471
|
+
id: `p-${++seq}-${Math.random().toString(36).slice(2, 6)}`,
|
|
472
|
+
file,
|
|
473
|
+
reason: reason ?? '',
|
|
474
|
+
at: new Date().toISOString(),
|
|
475
|
+
patches,
|
|
476
|
+
changes,
|
|
477
|
+
};
|
|
478
|
+
pending.set(record.id, record);
|
|
479
|
+
return { ...record };
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
listPending() {
|
|
483
|
+
return [...pending.values()];
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
applyPending(id) {
|
|
487
|
+
const record = pending.get(id);
|
|
488
|
+
if (!record) throw new Error(`待审变更 ${id} 不存在或已处理`);
|
|
489
|
+
pending.delete(id);
|
|
490
|
+
try {
|
|
491
|
+
return { ...this.patch({ file: record.file, patches: record.patches, reason: record.reason }), id };
|
|
492
|
+
} catch (e) {
|
|
493
|
+
pending.set(id, record);
|
|
494
|
+
throw e;
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
rejectPending(id) {
|
|
499
|
+
if (!pending.has(id)) throw new Error(`待审变更 ${id} 不存在或已处理`);
|
|
500
|
+
pending.delete(id);
|
|
501
|
+
return { ok: true, id };
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|