dsh-checklists 0.4.0 → 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/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))
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-checklists",
3
- "version": "0.4.0",
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",