nx-kv 0.1.1 → 0.2.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/CHANGELOG.md +100 -48
- package/README.md +38 -22
- package/assets/nx-kv/SKILL.md +21 -18
- package/assets/nx-kv/references/agent-workflow.md +9 -7
- package/assets/nx-kv/references/todo-commands.md +44 -20
- package/package.json +1 -1
- package/src/core/paths.js +1 -1
- package/src/modules/todo/index.js +288 -280
- package/src/modules/todo/service.js +61 -47
- package/src/modules/todo/view.jsx +285 -280
- package/src/runtime/cli.js +300 -299
- package/src/runtime/registry.js +11 -0
- package/src/runtime/spec.js +34 -8
- package/src/web/frontend/App.jsx +1 -1
- package/src/web/frontend/index.html +4 -0
- package/src/web/frontend/style.css +2 -1
- package/src/web/public/assets/{CliHints-3H3KOoDZ.js → CliHints-Bc3Me3vO.js} +1 -1
- package/src/web/public/assets/index-CmCYxz0e.css +1 -0
- package/src/web/public/assets/{index-jhTtYCb9.js → index-Dv9g9jmR.js} +2 -2
- package/src/web/public/assets/{view-CrL-DSJu.js → view-B8AnDK0i.js} +1 -1
- package/src/web/public/assets/{view-Du7Wv_-h.js → view-BKTVRs9U.js} +1 -1
- package/src/web/public/assets/view-Cdi5TUV1.js +2 -0
- package/src/web/public/index.html +6 -2
- package/src/web/public/assets/index-BnWex75m.css +0 -1
- package/src/web/public/assets/view-S0N5g6v1.js +0 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 清单 service —— 四把 key 上的读-改-写。
|
|
2
2
|
//
|
|
3
|
-
// ───
|
|
3
|
+
// ─── 必须知道的五条契约(前四条来自 Flutter 端 todo_submit_service.dart 的文件头注释)───
|
|
4
4
|
//
|
|
5
5
|
// 1. **id 分配必须扫「待办 + 冻结」**。冻结任务保留原 id,只扫待办的话,
|
|
6
|
-
// 待办清空后会从 1
|
|
6
|
+
// 待办清空后会从 1 重新分配,与冻结任务撞车。
|
|
7
7
|
// 实测佐证:todo:open 为空、todo:freeze 有 id 2/10/17/23/24/28,只扫 open 会分配 1。
|
|
8
8
|
//
|
|
9
9
|
// 2. **topic 不在快捷列表时,要连 `todo:topics` 一起写**,否则它不会出现在候选里。
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// 顺序是刻意的 —— open 先于 topics:万一 topics 写失败,任务本身不丢,
|
|
14
14
|
// 只是短期内不在快捷候选里;反过来则会丢失任务。
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// 5. **定位一律按内容(文本 ref),不按 id**。id 只是给人看的元数据——
|
|
17
|
+
// 它既会被复用(分配只扫 open + freeze),同一主题里也本来就有重复内容。
|
|
18
|
+
// 详见下方「定位」一节。
|
|
19
19
|
//
|
|
20
20
|
// ─── 时间格式(沿用既有数据的约定,不要统一)───
|
|
21
21
|
// createdAt / frozenAt:Dart `toIso8601String()` 风格,本地时间无时区
|
|
@@ -147,51 +147,64 @@ export function nextTaskId(open, freeze) {
|
|
|
147
147
|
return max + 1;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
// ───
|
|
150
|
+
// ─── 定位:一律按**内容**,不按 id ⚠️ ──────────────────────────────
|
|
151
151
|
//
|
|
152
|
-
// **
|
|
153
|
-
// 所以任务完成、open 清空之后,新任务会重新用上已被完成任务占过的 id。
|
|
154
|
-
// 实测这份数据里 done 有 68 条、其中 id=29 有 7 条。
|
|
152
|
+
// **id 非常容易重复,不足以定位一条任务。** 两条独立的原因:
|
|
155
153
|
//
|
|
156
|
-
//
|
|
154
|
+
// 1. 分配只扫 open + freeze(契约 1),任务完成、open 清空后新任务会重新
|
|
155
|
+
// 用上已占过的 id。实测 done 有 68 条、其中 id=29 有 7 条。
|
|
156
|
+
// 2. 同一个主题里,写「修复登录页」这样内容的任务本来就会反复出现——
|
|
157
|
+
// 即使 id 不撞,光看 id 也说不清调用方指的是哪一条。
|
|
158
|
+
//
|
|
159
|
+
// 所以 `id` 在本项目里**只是给人和外部系统看的元数据**:继续分配、继续出现在
|
|
160
|
+
// 输出里,但**不再是任何命令的入参**。定位一律用文本内容,见下面的 ref。
|
|
161
|
+
//
|
|
162
|
+
// 由此推出两条硬约束,违反其一都会静默改错数据:
|
|
157
163
|
//
|
|
158
164
|
// 1. **命中多条时必须消歧,不能猜一条。** 用 `findIndex` 取第一条,
|
|
159
165
|
// 在 done 上取到的是最老的记录,而调用方往往以为自己在操作刚创建的那条。
|
|
160
|
-
// 2. **变更必须按 index 定位,禁止 `filter(t => t.
|
|
161
|
-
//
|
|
166
|
+
// 2. **变更必须按 index 定位,禁止 `filter(t => t.text !== ref)`。**
|
|
167
|
+
// 后者会把**所有**同内容的条目一起删掉。
|
|
162
168
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
169
|
+
/** 找出所有内容匹配的条目(跨桶,含同桶内重复)。
|
|
170
|
+
* 用整串精确比对——不做前缀/模糊匹配,避免「看起来像」被当成「就是它」。 */
|
|
171
|
+
export function locateAll(all, ref) {
|
|
172
|
+
const want = String(ref ?? '');
|
|
165
173
|
const hits = [];
|
|
166
174
|
for (const bucket of BUCKETS) {
|
|
167
175
|
all[bucket].forEach((task, index) => {
|
|
168
|
-
if (task.
|
|
176
|
+
if (task.text === want) hits.push({ bucket, index, task });
|
|
169
177
|
});
|
|
170
178
|
}
|
|
171
179
|
return hits;
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/**
|
|
175
|
-
*
|
|
183
|
+
* 定位唯一一条。`ref` 是任务**内容**(整串)。
|
|
176
184
|
*
|
|
177
185
|
* 消歧手段有两个,按需组合:
|
|
178
|
-
* `--topic <名字>`
|
|
179
|
-
* `--pick <n>`
|
|
186
|
+
* `--topic <名字>` 按主题收窄(同内容散落在不同主题时的快捷方式)
|
|
187
|
+
* `--pick <n>` 按编号选择(最通用;同内容且同主题时唯一可靠的手段)
|
|
180
188
|
*
|
|
181
|
-
*
|
|
182
|
-
* 光靠 --topic 永远收敛不到一条。报错时把候选项编号列出来,
|
|
183
|
-
* 调用方(人或 agent)照着选即可——**绝不替调用方猜**。
|
|
189
|
+
* 报错时把候选项编号列出来,调用方(人或 agent)照着选即可——**绝不替调用方猜**。
|
|
184
190
|
*/
|
|
185
|
-
export function locateUnique(all,
|
|
186
|
-
const
|
|
191
|
+
export function locateUnique(all, ref, { topic, pick } = {}) {
|
|
192
|
+
const want = String(ref ?? '');
|
|
193
|
+
|
|
194
|
+
// 空 ref 是**参数错误**而不是 NOT_FOUND:绝大多数时候是忘了传 --ref,
|
|
195
|
+
// 而空串恰好会匹配上数据里 text 为空的坏条目——那等于随机改一条,必须挡在门外。
|
|
196
|
+
if (!want) throw badInput('task 内容不能为空(用 --ref 指定要操作的任务文本)');
|
|
197
|
+
|
|
198
|
+
const allHits = locateAll(all, want);
|
|
187
199
|
const hits = topic ? allHits.filter((h) => h.task.topic === topic) : allHits;
|
|
200
|
+
const shown = want.length > 40 ? want.slice(0, 40) + '…' : want;
|
|
188
201
|
|
|
189
202
|
if (!hits.length) {
|
|
190
203
|
if (allHits.length) {
|
|
191
204
|
const topics = [...new Set(allHits.map((h) => h.task.topic))].join(', ');
|
|
192
|
-
throw notFound(`
|
|
205
|
+
throw notFound(`task「${shown}」存在,但 topic 不是 ${topic}(实际 topic: ${topics})`);
|
|
193
206
|
}
|
|
194
|
-
throw notFound(`task
|
|
207
|
+
throw notFound(`task「${shown}」不存在(待办 / 已完成 / 冻结里都没有)`);
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
if (pick !== undefined && pick !== null) {
|
|
@@ -204,12 +217,12 @@ export function locateUnique(all, id, { topic, pick } = {}) {
|
|
|
204
217
|
|
|
205
218
|
if (hits.length > 1) {
|
|
206
219
|
const list = hits
|
|
207
|
-
.map((h, i) => ` [${i}] ${h.bucket.padEnd(6)} ${h.task.topic.padEnd(5)} ${h.task.createdAt}
|
|
220
|
+
.map((h, i) => ` [${i}] ${h.bucket.padEnd(6)} ${h.task.topic.padEnd(5)} ${h.task.createdAt}`)
|
|
208
221
|
.join('\n');
|
|
209
222
|
throw conflict(
|
|
210
|
-
`
|
|
223
|
+
`task「${shown}」命中 ${hits.length} 条,无法确定是哪一条。用 --pick <n> 选择:\n${list}` +
|
|
211
224
|
(topic ? '' : `\n(也可先用 --topic <名字> 收窄)`),
|
|
212
|
-
{ candidates: hits.map((h) => ({ bucket: h.bucket, topic: h.task.topic, createdAt: h.task.createdAt
|
|
225
|
+
{ candidates: hits.map((h, i) => ({ pick: i, bucket: h.bucket, topic: h.task.topic, createdAt: h.task.createdAt })) }
|
|
213
226
|
);
|
|
214
227
|
}
|
|
215
228
|
return hits[0];
|
|
@@ -257,9 +270,9 @@ export async function listTodos({ topic, status, groupId } = {}) {
|
|
|
257
270
|
};
|
|
258
271
|
}
|
|
259
272
|
|
|
260
|
-
export async function getTodo(
|
|
273
|
+
export async function getTodo(ref, { topic, pick, groupId } = {}) {
|
|
261
274
|
const all = await loadAll(groupId);
|
|
262
|
-
const hit = locateUnique(all,
|
|
275
|
+
const hit = locateUnique(all, ref, { topic, pick });
|
|
263
276
|
return { ...hit.task, bucket: hit.bucket };
|
|
264
277
|
}
|
|
265
278
|
|
|
@@ -302,11 +315,12 @@ export async function addTodo({ topic, text, groupId }) {
|
|
|
302
315
|
}
|
|
303
316
|
|
|
304
317
|
/** 编辑任务:只改传入的字段(PATCH 语义),未传的保持原值。
|
|
318
|
+
* 定位用 `ref`(当前内容);`--text` 才是要改成的新内容。
|
|
305
319
|
* `--match-topic` 是用来**消歧**的定位条件,与要改的 `--topic` 是两回事——
|
|
306
320
|
* 合并成一个会让「把 qus 改成 go」这种操作无从表达。 */
|
|
307
|
-
export async function updateTodo(
|
|
321
|
+
export async function updateTodo(ref, { topic, text, note, matchTopic, pick, groupId } = {}) {
|
|
308
322
|
const all = await loadAll(groupId);
|
|
309
|
-
const hit = locateUnique(all,
|
|
323
|
+
const hit = locateUnique(all, ref, { topic: matchTopic, pick });
|
|
310
324
|
|
|
311
325
|
if (topic === undefined && text === undefined && note === undefined) {
|
|
312
326
|
throw badInput('至少要给出一个要改的字段(--topic / --text / --note)');
|
|
@@ -338,10 +352,10 @@ export async function updateTodo(id, { topic, text, note, matchTopic, pick, grou
|
|
|
338
352
|
return { task: next, bucket: hit.bucket, topicAdded };
|
|
339
353
|
}
|
|
340
354
|
|
|
341
|
-
/** 删除任务。只移除**命中的那一条**(按 index
|
|
342
|
-
export async function removeTodo(
|
|
355
|
+
/** 删除任务。只移除**命中的那一条**(按 index),不按内容批量删。 */
|
|
356
|
+
export async function removeTodo(ref, { topic, pick, groupId } = {}) {
|
|
343
357
|
const all = await loadAll(groupId);
|
|
344
|
-
const hit = locateUnique(all,
|
|
358
|
+
const hit = locateUnique(all, ref, { topic, pick });
|
|
345
359
|
|
|
346
360
|
const bucket = [...all[hit.bucket]];
|
|
347
361
|
bucket.splice(hit.index, 1);
|
|
@@ -350,11 +364,11 @@ export async function removeTodo(id, { topic, pick, groupId } = {}) {
|
|
|
350
364
|
}
|
|
351
365
|
|
|
352
366
|
/** 标记完成:待办 → 已完成,补 doneAt 与 note */
|
|
353
|
-
export async function doneTodo(
|
|
367
|
+
export async function doneTodo(ref, { result, topic, pick, groupId } = {}) {
|
|
354
368
|
const all = await loadAll(groupId);
|
|
355
|
-
const hit = locateUnique(all,
|
|
356
|
-
if (hit.bucket === 'done') throw conflict(`task
|
|
357
|
-
if (hit.bucket === 'freeze') throw conflict(`task
|
|
369
|
+
const hit = locateUnique(all, ref, { topic, pick });
|
|
370
|
+
if (hit.bucket === 'done') throw conflict(`task「${hit.task.text}」已经是完成状态`);
|
|
371
|
+
if (hit.bucket === 'freeze') throw conflict(`task「${hit.task.text}」处于冻结中,先解冻再完成`);
|
|
358
372
|
|
|
359
373
|
const finished = {
|
|
360
374
|
...hit.task,
|
|
@@ -362,7 +376,7 @@ export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
|
|
|
362
376
|
note: result === undefined ? hit.task.note : String(result),
|
|
363
377
|
};
|
|
364
378
|
|
|
365
|
-
// 按 index 移除,再追加到 done —— 不能用 filter(
|
|
379
|
+
// 按 index 移除,再追加到 done —— 不能用 filter(text !== ref),那会连带删掉同内容的其他条目
|
|
366
380
|
const from = [...all[hit.bucket]];
|
|
367
381
|
from.splice(hit.index, 1);
|
|
368
382
|
const done = [...all.done, finished];
|
|
@@ -375,10 +389,10 @@ export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
|
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
/** 冻结:→ 冻结(id 保留) */
|
|
378
|
-
export async function freezeTodo(
|
|
392
|
+
export async function freezeTodo(ref, { topic, pick, groupId } = {}) {
|
|
379
393
|
const all = await loadAll(groupId);
|
|
380
|
-
const hit = locateUnique(all,
|
|
381
|
-
if (hit.bucket === 'freeze') throw conflict(`task
|
|
394
|
+
const hit = locateUnique(all, ref, { topic, pick });
|
|
395
|
+
if (hit.bucket === 'freeze') throw conflict(`task「${hit.task.text}」已经处于冻结`);
|
|
382
396
|
|
|
383
397
|
const frozen = { ...hit.task, frozenAt: dartNow() };
|
|
384
398
|
const from = [...all[hit.bucket]];
|
|
@@ -390,12 +404,12 @@ export async function freezeTodo(id, { topic, pick, groupId } = {}) {
|
|
|
390
404
|
}
|
|
391
405
|
|
|
392
406
|
/** 解冻:冻结 → 待办。id 与现有待办冲突时换新 id(契约 1 的下游) */
|
|
393
|
-
export async function unfreezeTodo(
|
|
407
|
+
export async function unfreezeTodo(ref, { topic, pick, groupId } = {}) {
|
|
394
408
|
const all = await loadAll(groupId);
|
|
395
|
-
const hit = locateUnique(all,
|
|
396
|
-
if (hit.bucket !== 'freeze') throw conflict(`task
|
|
409
|
+
const hit = locateUnique(all, ref, { topic, pick });
|
|
410
|
+
if (hit.bucket !== 'freeze') throw conflict(`task「${hit.task.text}」不在冻结区(当前在 ${hit.bucket})`);
|
|
397
411
|
|
|
398
|
-
const clashes = all.open.some((t) => t.id === id);
|
|
412
|
+
const clashes = all.open.some((t) => t.id === hit.task.id);
|
|
399
413
|
const revived = {
|
|
400
414
|
...hit.task,
|
|
401
415
|
id: clashes ? nextTaskId(all.open, all.freeze) : hit.task.id,
|