dsh-checklists 0.3.3 → 0.5.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/README.md CHANGED
@@ -28,6 +28,7 @@ If the agent forgets to mark something, you say so in chat and it fixes it.
28
28
  | `checklist_edit` | Changes an item's text (not its state) |
29
29
  | `checklist_list` | Full tree with ids and states, for when the digest is truncated |
30
30
  | `checklist_delete` | Deletes a list (by id or exact title) |
31
+ | `checklist_remove_item` | Removes one item and everything nested under it |
31
32
  | `checklist_rename` | Renames a list; titles stay unique within the session |
32
33
  | `checklist_template_save` | Saves a list as a reusable template for future sessions |
33
34
 
@@ -49,7 +50,7 @@ From a local build or tarball:
49
50
  npm install
50
51
  npm run build
51
52
  npm pack
52
- dsh plugin --profile dev add ./dsh-checklists-0.3.2.tgz
53
+ dsh plugin --profile dev add ./dsh-checklists-0.4.0.tgz
53
54
  dsh --profile dev --dump-config # the tool-checklist row should appear
54
55
  ```
55
56
 
package/lib/gate.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Turn-end gate: decides whether a turn is closing with the checklist stale.
3
+ *
4
+ * Kept pure on purpose — the host event wiring lives in `index.ts`, so the rule
5
+ * itself is testable without a running harness and can be reasoned about by
6
+ * reading one function.
7
+ *
8
+ * The gate never guesses *what* was completed. It only notices the shape of the
9
+ * problem: real work happened, the checklist was not touched, and something is
10
+ * still marked as in progress.
11
+ */
12
+ /** Observation endpoint for the gate's last decisions. */
13
+ export const NUDGE_ROUTE = '/dsh-checklist/nudge';
14
+ /**
15
+ * Tools whose result can change the workspace. `run_code` is included because
16
+ * the PTC transport can reach any tool behind it.
17
+ */
18
+ export const MUTATING_TOOLS = new Set([
19
+ 'bash',
20
+ 'pwsh',
21
+ 'write',
22
+ 'edit',
23
+ 'str_replace_editor',
24
+ 'apply_patch',
25
+ 'run_code',
26
+ ]);
27
+ /** Tools that already keep a checklist current. */
28
+ const CHECKLIST_WRITES = new Set([
29
+ 'checklist_create',
30
+ 'checklist_add',
31
+ 'checklist_check',
32
+ 'checklist_edit',
33
+ 'checklist_remove_item',
34
+ 'checklist_delete',
35
+ 'checklist_rename',
36
+ ]);
37
+ export function isChecklistWrite(name) {
38
+ return CHECKLIST_WRITES.has(name);
39
+ }
40
+ /**
41
+ * Decide whether the closing turn should be challenged.
42
+ *
43
+ * @param activity - what the turn did.
44
+ * @param doingCount - items currently in `doing` across the session.
45
+ * @returns the decision and a short human-readable reason, logged either way.
46
+ */
47
+ export function decideNudge(activity, doingCount) {
48
+ if (!activity.mutated)
49
+ return { nudge: false, reason: 'no mutating tool calls' };
50
+ if (activity.checklistWrite)
51
+ return { nudge: false, reason: 'checklist already updated' };
52
+ if (doingCount === 0)
53
+ return { nudge: false, reason: 'nothing in progress' };
54
+ return { nudge: true, reason: `${doingCount} item(s) still doing after workspace changes` };
55
+ }
package/lib/index.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * document is the single source of truth.
12
12
  */
13
13
  import { renderDigest } from './digest.js';
14
+ import { decideNudge, isChecklistWrite, MUTATING_TOOLS, NUDGE_ROUTE } from './gate.js';
15
+ import { countState } from './model.js';
14
16
  import { serveProbe, PROBE_ROUTE } from './probe.js';
15
17
  import { serveItemUpdate, serveState, ITEM_ROUTE, STATE_ROUTE } from './route.js';
16
18
  import { ChecklistStore } from './store.js';
@@ -44,6 +46,52 @@ export function apply(ctx, config = {}) {
44
46
  if (config.pruneDays !== 0) {
45
47
  void store.prune(config.pruneDays ?? 14).catch(() => undefined);
46
48
  }
49
+ // --- Turn-end gate (observation mode) ------------------------------------
50
+ // Watches one turn's tool activity and decides, as the turn closes, whether
51
+ // the checklist was left stale. This release only records the decision — the
52
+ // event is not exercised yet — so enabling `steer` is a one-line follow-up
53
+ // once the log shows it fires when it should.
54
+ const activity = new Map();
55
+ const gateLog = [];
56
+ const events = ctx;
57
+ ctx.effect(() => events.on('tools/result', (payload) => {
58
+ const event = payload;
59
+ const toolName = event.exec?.name;
60
+ const agentId = event.exec?.agent?.id;
61
+ if (typeof toolName !== 'string' || typeof agentId !== 'string')
62
+ return;
63
+ const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
64
+ if (MUTATING_TOOLS.has(toolName))
65
+ current.mutated = true;
66
+ if (isChecklistWrite(toolName))
67
+ current.checklistWrite = true;
68
+ activity.set(agentId, current);
69
+ }));
70
+ ctx.effect(() => events.on('agent/turn-stopping', (payload) => {
71
+ const event = payload;
72
+ const agentId = event.agent?.id;
73
+ if (typeof agentId !== 'string')
74
+ return;
75
+ const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
76
+ activity.set(agentId, { mutated: false, checklistWrite: false });
77
+ const doc = store.peek(agentId);
78
+ let doing = 0;
79
+ if (doc)
80
+ for (const list of doc.lists)
81
+ doing += countState(list.items, 'doing');
82
+ const decision = decideNudge(current, doing);
83
+ if (!decision.nudge && process.env.DSH_CHECKLIST_DEBUG !== '1')
84
+ return;
85
+ gateLog.push({
86
+ at: new Date().toISOString(),
87
+ sessionId: agentId,
88
+ nudge: decision.nudge,
89
+ reason: decision.reason,
90
+ doing,
91
+ });
92
+ if (gateLog.length > 20)
93
+ gateLog.shift();
94
+ }));
47
95
  let loggedScope = false;
48
96
  ctx.inject(['systemPrompt'], (promptCtx) => {
49
97
  promptCtx.systemPrompt.context({
@@ -93,5 +141,19 @@ export function apply(ctx, config = {}) {
93
141
  serveProbe(req, res);
94
142
  },
95
143
  }), 'dsh-checklist: client probe route');
144
+ // What the turn-end gate decided, for verification before it steers.
145
+ webCtx.effect(() => webCtx.webServer.register({
146
+ kind: 'exact',
147
+ path: NUDGE_ROUTE,
148
+ handler: (req, res) => {
149
+ const body = Buffer.from(`${JSON.stringify({ mode: 'observe', entries: gateLog }, null, 2)}\n`, 'utf8');
150
+ res.writeHead(200, {
151
+ 'content-type': 'application/json; charset=utf-8',
152
+ 'content-length': String(body.byteLength),
153
+ 'cache-control': 'no-store',
154
+ });
155
+ res.end(body);
156
+ },
157
+ }), 'dsh-checklist: nudge log route');
96
158
  });
97
159
  }
package/lib/model.js CHANGED
@@ -9,12 +9,25 @@ export const ITEM_STATES = ['todo', 'doing', 'done', 'blocked'];
9
9
  export function emptyDoc(sessionId) {
10
10
  return { version: 1, sessionId, lists: [] };
11
11
  }
12
- /** Mint the next `<prefix>N` identifier that is not already in `used`. */
12
+ /**
13
+ * Mint the next `<prefix>N` identifier.
14
+ *
15
+ * Numbers start after the highest one already in `used`, so a removed item's id
16
+ * is not handed to the next arrival: a freed `i3` stays dead instead of
17
+ * silently aliasing a different item. The high-water mark is not persisted, so
18
+ * removing the newest item and adding another can still reuse that id — fully
19
+ * closing the hole needs a stored counter, which means a schema change.
20
+ */
13
21
  export function mintId(prefix, used) {
14
- let n = 1;
15
- while (used.has(`${prefix}${n}`))
16
- n += 1;
17
- return `${prefix}${n}`;
22
+ let highest = 0;
23
+ for (const id of used) {
24
+ if (!id.startsWith(prefix))
25
+ continue;
26
+ const suffix = Number(id.slice(prefix.length));
27
+ if (Number.isInteger(suffix) && suffix > highest)
28
+ highest = suffix;
29
+ }
30
+ return `${prefix}${highest + 1}`;
18
31
  }
19
32
  /**
20
33
  * Build an item tree from plain lines.
@@ -80,6 +93,16 @@ export function countItems(items) {
80
93
  }
81
94
  return { total, done };
82
95
  }
96
+ /** Count items in one state across a tree. */
97
+ export function countState(items, state) {
98
+ let total = 0;
99
+ for (const item of items) {
100
+ if (item.state === state)
101
+ total += 1;
102
+ total += countState(item.children, state);
103
+ }
104
+ return total;
105
+ }
83
106
  /**
84
107
  * Resolve one model-supplied reference against the session's checklists.
85
108
  *
@@ -89,14 +112,17 @@ export function countItems(items) {
89
112
  *
90
113
  * @param lists - the session's checklists.
91
114
  * @param ref - a model-supplied item id, text, or prefix.
92
- * @param listId - optional checklist id to search within.
115
+ * @param listId - optional checklist id **or exact title** to search within.
93
116
  * @returns the hit, the ambiguity, or a miss.
94
117
  */
95
118
  export function resolveRef(lists, ref, listId) {
96
119
  const needles = ref.trim().toLowerCase();
97
120
  if (needles.length === 0)
98
121
  return { kind: 'miss' };
99
- const scoped = listId ? lists.filter((list) => list.id === listId) : lists;
122
+ // Accept a title here as well as an id: every other tool does, and a model
123
+ // that just created "Release 2.3" will pass that back, not "l1".
124
+ const scope = listId ? findList(lists, listId) : undefined;
125
+ const scoped = listId ? (scope ? [scope] : []) : lists;
100
126
  const entries = [];
101
127
  for (const list of scoped) {
102
128
  for (const { item } of flatten(list.items))
@@ -147,6 +173,27 @@ export function findItemById(lists, id) {
147
173
  }
148
174
  return undefined;
149
175
  }
176
+ /**
177
+ * Remove one item (and its whole subtree) by exact id.
178
+ *
179
+ * Mutates the array in place, so callers must pass a document they own — the
180
+ * store's `update` already hands out a clone.
181
+ *
182
+ * @param items - sibling list to search, at any depth.
183
+ * @param id - exact item id.
184
+ * @returns the removed node, or `undefined` when no item has that id.
185
+ */
186
+ export function removeItemById(items, id) {
187
+ const index = items.findIndex((entry) => entry.id === id);
188
+ if (index >= 0)
189
+ return items.splice(index, 1)[0];
190
+ for (const item of items) {
191
+ const removed = removeItemById(item.children, id);
192
+ if (removed)
193
+ return removed;
194
+ }
195
+ return undefined;
196
+ }
150
197
  /**
151
198
  * Validate a checklist rename before applying it.
152
199
  *
package/lib/tools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * the single source of truth and the sidebar (phase 2) is a read-only mirror.
6
6
  */
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
- import { buildItems, checkRename, countItems, findList, flatten, ITEM_STATES, isItemState, mintId, nextItem, resolveRef, toLines, } from './model.js';
8
+ import { buildItems, checkRename, countItems, findList, flatten, ITEM_STATES, isItemState, mintId, nextItem, removeItemById, resolveRef, toLines, } from './model.js';
9
9
  /** Ships with the plugin so a first run has something to instantiate. */
10
10
  const DEFAULT_TEMPLATES = {
11
11
  release: [
@@ -542,6 +542,78 @@ export function registerChecklistTools(ctx, store, config = {}) {
542
542
  });
543
543
  },
544
544
  })));
545
+ ctx.effect(() => ctx.tools.register(defineTool({
546
+ name: 'checklist_remove_item',
547
+ description: 'Remove one item — and everything nested under it — from a checklist in the CURRENT session. '
548
+ + 'Match it by id or exact text. Use it to drop work that no longer applies; '
549
+ + 'to change the item instead, use checklist_edit.',
550
+ parameters: {
551
+ listId: {
552
+ type: 'string',
553
+ required: true,
554
+ description: 'Checklist id, or its exact title.',
555
+ },
556
+ item: {
557
+ type: 'string',
558
+ required: true,
559
+ description: 'Item id or exact item text.',
560
+ },
561
+ },
562
+ output: {
563
+ schema: {
564
+ type: 'object',
565
+ additionalProperties: false,
566
+ properties: {
567
+ listId: { type: 'string', required: true },
568
+ itemId: { type: 'string', required: true },
569
+ text: { type: 'string', required: true },
570
+ removed: { type: 'integer', required: true },
571
+ },
572
+ },
573
+ render: (_args, value) => {
574
+ const result = value;
575
+ const extra = result.removed > 1 ? ` (+${result.removed - 1} nested)` : '';
576
+ return [{ type: 'text', text: `Removed "${result.text}"${extra}.` }];
577
+ },
578
+ },
579
+ timeoutMs: 10_000,
580
+ async execute(args, exec) {
581
+ const input = args;
582
+ const sessionId = sessionOf(exec);
583
+ const listRef = text(input.listId).trim();
584
+ const itemRef = text(input.item).trim();
585
+ if (listRef.length === 0)
586
+ throw new Error('checklist_remove_item: listId must not be empty');
587
+ if (itemRef.length === 0)
588
+ throw new Error('checklist_remove_item: item must not be empty');
589
+ return store.update(sessionId, (doc) => {
590
+ const list = findList(doc.lists, listRef);
591
+ if (!list) {
592
+ const available = doc.lists.map((entry) => `${entry.id} (${entry.title})`).join(', ');
593
+ throw new Error(`checklist_remove_item: no checklist "${listRef}" in this session. `
594
+ + `Available: ${available.length > 0 ? available : 'none'}`);
595
+ }
596
+ const found = resolveRef([list], itemRef);
597
+ if (found.kind === 'ambiguous') {
598
+ const options = found.candidates.map((entry) => `${entry.item.id} (${entry.item.text})`).join(', ');
599
+ throw new Error(`checklist_remove_item: "${itemRef}" is ambiguous; use an id: ${options}`);
600
+ }
601
+ if (found.kind === 'miss') {
602
+ throw new Error(`checklist_remove_item: no item "${itemRef}" in "${list.title}". Call checklist_list for ids.`);
603
+ }
604
+ const removed = removeItemById(list.items, found.hit.item.id);
605
+ if (!removed) {
606
+ throw new Error(`checklist_remove_item: item "${itemRef}" disappeared from "${list.title}"`);
607
+ }
608
+ return {
609
+ listId: list.id,
610
+ itemId: removed.id,
611
+ text: removed.text,
612
+ removed: countItems([removed]).total,
613
+ };
614
+ });
615
+ },
616
+ })));
545
617
  ctx.effect(() => ctx.tools.register(defineTool({
546
618
  name: 'checklist_delete',
547
619
  description: 'Delete one checklist from the CURRENT session. The list and its items are gone; there is no archive. '
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Turn-end gate: decides whether a turn is closing with the checklist stale.
3
+ *
4
+ * Kept pure on purpose — the host event wiring lives in `index.ts`, so the rule
5
+ * itself is testable without a running harness and can be reasoned about by
6
+ * reading one function.
7
+ *
8
+ * The gate never guesses *what* was completed. It only notices the shape of the
9
+ * problem: real work happened, the checklist was not touched, and something is
10
+ * still marked as in progress.
11
+ */
12
+ export interface TurnActivity {
13
+ /** The turn ran a tool that can change the workspace. */
14
+ mutated: boolean;
15
+ /** The turn already wrote to a checklist. */
16
+ checklistWrite: boolean;
17
+ }
18
+ /** Observation endpoint for the gate's last decisions. */
19
+ export declare const NUDGE_ROUTE = "/dsh-checklist/nudge";
20
+ export interface NudgeDecision {
21
+ nudge: boolean;
22
+ reason: string;
23
+ }
24
+ /**
25
+ * Tools whose result can change the workspace. `run_code` is included because
26
+ * the PTC transport can reach any tool behind it.
27
+ */
28
+ export declare const MUTATING_TOOLS: ReadonlySet<string>;
29
+ export declare function isChecklistWrite(name: string): boolean;
30
+ /**
31
+ * Decide whether the closing turn should be challenged.
32
+ *
33
+ * @param activity - what the turn did.
34
+ * @param doingCount - items currently in `doing` across the session.
35
+ * @returns the decision and a short human-readable reason, logged either way.
36
+ */
37
+ export declare function decideNudge(activity: TurnActivity, doingCount: number): NudgeDecision;
@@ -25,7 +25,15 @@ export interface SessionDoc {
25
25
  lists: Checklist[];
26
26
  }
27
27
  export declare function emptyDoc(sessionId: string): SessionDoc;
28
- /** Mint the next `<prefix>N` identifier that is not already in `used`. */
28
+ /**
29
+ * Mint the next `<prefix>N` identifier.
30
+ *
31
+ * Numbers start after the highest one already in `used`, so a removed item's id
32
+ * is not handed to the next arrival: a freed `i3` stays dead instead of
33
+ * silently aliasing a different item. The high-water mark is not persisted, so
34
+ * removing the newest item and adding another can still reuse that id — fully
35
+ * closing the hole needs a stored counter, which means a schema change.
36
+ */
29
37
  export declare function mintId(prefix: string, used: ReadonlySet<string>): string;
30
38
  /**
31
39
  * Build an item tree from plain lines.
@@ -53,6 +61,8 @@ export interface ItemCounts {
53
61
  done: number;
54
62
  }
55
63
  export declare function countItems(items: readonly ChecklistItem[]): ItemCounts;
64
+ /** Count items in one state across a tree. */
65
+ export declare function countState(items: readonly ChecklistItem[], state: ItemState): number;
56
66
  export interface RefHit {
57
67
  list: Checklist;
58
68
  item: ChecklistItem;
@@ -75,7 +85,7 @@ export type RefResult = {
75
85
  *
76
86
  * @param lists - the session's checklists.
77
87
  * @param ref - a model-supplied item id, text, or prefix.
78
- * @param listId - optional checklist id to search within.
88
+ * @param listId - optional checklist id **or exact title** to search within.
79
89
  * @returns the hit, the ambiguity, or a miss.
80
90
  */
81
91
  export declare function resolveRef(lists: readonly Checklist[], ref: string, listId?: string): RefResult;
@@ -97,6 +107,17 @@ export declare function findItemById(lists: readonly Checklist[], id: string): {
97
107
  list: Checklist;
98
108
  item: ChecklistItem;
99
109
  } | undefined;
110
+ /**
111
+ * Remove one item (and its whole subtree) by exact id.
112
+ *
113
+ * Mutates the array in place, so callers must pass a document they own — the
114
+ * store's `update` already hands out a clone.
115
+ *
116
+ * @param items - sibling list to search, at any depth.
117
+ * @param id - exact item id.
118
+ * @returns the removed node, or `undefined` when no item has that id.
119
+ */
120
+ export declare function removeItemById(items: ChecklistItem[], id: string): ChecklistItem | undefined;
100
121
  export type RenameCheck = {
101
122
  kind: 'ok';
102
123
  list: Checklist;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-checklists",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "Session-scoped checklists for DeepSeek Harness: the agent tracks work as a tree, the model always sees a compact digest, and a read-only sidebar tab mirrors it.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",