dsh-checklists 0.5.1 → 0.6.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
@@ -50,7 +50,7 @@ From a local build or tarball:
50
50
  npm install
51
51
  npm run build
52
52
  npm pack
53
- dsh plugin --profile dev add ./dsh-checklists-0.4.0.tgz
53
+ dsh plugin --profile dev add ./dsh-checklists-0.6.0.tgz
54
54
  dsh --profile dev --dump-config # the tool-checklist row should appear
55
55
  ```
56
56
 
@@ -68,6 +68,7 @@ Override the defaults on the plugin's entry in the profile's `cordis.patch.yml`:
68
68
  maxDigestLines: 12 # lines of the injected digest
69
69
  maxItemsPerList: 200 # per-list item cap
70
70
  pruneDays: 14 # prune untouched sessions after N days (0 = never)
71
+ nudge: steer # steer | observe | off — see "Turn-end gate"
71
72
  # storageRoot: "D:/data/dsh-checklists"
72
73
  # templates:
73
74
  # my-flow: ["Step 1", " Sub-step", "Step 2"]
@@ -113,8 +114,42 @@ the agent's edit.
113
114
  |---|---|---|
114
115
  | `/dsh-checklist/state` | GET | One session's state (read-only, `no-store`) |
115
116
  | `/dsh-checklist/item` | POST | Edit an item's text, with a conflict guard |
117
+ | `/dsh-checklist/nudge` | GET | Recent turn-end gate decisions |
116
118
  | `/dsh-checklist/client-probe` | GET | Client load diagnostics |
117
119
 
120
+ ## Turn-end gate
121
+
122
+ A checklist only helps while it is true. The gate closes the most common gap:
123
+ a turn does real work, forgets to mark it, and the list is stale by the time you
124
+ look at it.
125
+
126
+ When a turn is about to close, the plugin checks exactly three things:
127
+
128
+ 1. Did the turn run a tool that changes the workspace (`bash`, `edit`, `write`, …)?
129
+ 2. Did it skip every checklist write?
130
+ 3. Is anything still marked `doing`?
131
+
132
+ If all three hold, the agent gets one short steering message before the turn ends:
133
+
134
+ > Before you finish: N checklist item(s) are still marked "doing". If you
135
+ > completed any of them this turn, update them with checklist_check and say what
136
+ > proves it. If not, leave them as they are — do not mark work you did not do.
137
+
138
+ It never guesses **what** was completed — only that the bookkeeping was skipped.
139
+ At most one nudge per turn, and none when the turn was pure conversation or the
140
+ checklist already changed.
141
+
142
+ Inspect recent decisions at `GET /dsh-checklist/nudge`:
143
+
144
+ ```jsonc
145
+ { "mode": "steer", "entries": [
146
+ { "source": "turn-stopping", "nudge": true, "steered": true,
147
+ "reason": "1 item(s) still doing after workspace changes", "doing": 1 }
148
+ ] }
149
+ ```
150
+
151
+ `nudge: observe` records without interrupting; `nudge: off` disables the gate.
152
+
118
153
  ## Language
119
154
 
120
155
  The panel follows the harness locale: **Spanish** when `locale` starts with `es`,
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * The agent is the only writer. There is no client write path: the session
11
11
  * document is the single source of truth.
12
12
  */
13
+ import { randomUUID } from 'node:crypto';
13
14
  import { renderDigest } from './digest.js';
14
15
  import { decideNudge, isChecklistWrite, MUTATING_TOOLS, NUDGE_ROUTE } from './gate.js';
15
16
  import { countState } from './model.js';
@@ -19,6 +20,26 @@ import { ChecklistStore } from './store.js';
19
20
  import { registerChecklistTools } from './tools.js';
20
21
  export const name = 'dsh-checklist';
21
22
  export const inject = ['tools'];
23
+ /**
24
+ * Build the steering message.
25
+ *
26
+ * Model-facing, so English. It states the observation, asks for evidence, and
27
+ * explicitly allows leaving an item untouched — a gate that demanded progress
28
+ * would push the model into marking work it never did.
29
+ */
30
+ function buildSteerMessage(doing) {
31
+ return {
32
+ id: randomUUID(),
33
+ role: 'user',
34
+ content: [{
35
+ type: 'text',
36
+ text: `Before you finish: ${doing} checklist item(s) are still marked "doing". `
37
+ + 'If you completed any of them this turn, update them with checklist_check and say what '
38
+ + 'proves it. If not, leave them as they are — do not mark work you did not do.',
39
+ }],
40
+ source: { kind: 'user' },
41
+ };
42
+ }
22
43
  /**
23
44
  * Resolve which session this prompt assembly belongs to.
24
45
  *
@@ -46,11 +67,11 @@ export function apply(ctx, config = {}) {
46
67
  if (config.pruneDays !== 0) {
47
68
  void store.prune(config.pruneDays ?? 14).catch(() => undefined);
48
69
  }
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.
70
+ // --- Turn-end gate --------------------------------------------------------
71
+ // Watches one turn's tool activity and, as the turn closes, decides whether
72
+ // the checklist was left stale. In `steer` mode the agent gets a message
73
+ // before the turn is allowed to end; `observe` only records the decision.
74
+ const nudgeMode = config.nudge ?? 'steer';
54
75
  const activity = new Map();
55
76
  const gateLog = [];
56
77
  const events = ctx;
@@ -69,7 +90,8 @@ export function apply(ctx, config = {}) {
69
90
  current.checklistWrite = true;
70
91
  activity.set(agentId, current);
71
92
  }));
72
- const evaluate = (agentId, source) => {
93
+ const evaluate = (agent, source) => {
94
+ const agentId = agent.id;
73
95
  const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
74
96
  activity.set(agentId, { mutated: false, checklistWrite: false });
75
97
  const doc = store.peek(agentId);
@@ -78,34 +100,55 @@ export function apply(ctx, config = {}) {
78
100
  for (const list of doc.lists)
79
101
  doing += countState(list.items, 'doing');
80
102
  const decision = decideNudge(current, doing);
81
- // Log every decision while the gate is in observe mode: an empty log must
82
- // mean "the event never fired", never "the answer was no".
103
+ let steered = false;
104
+ let reason = decision.reason;
105
+ if (decision.nudge && nudgeMode === 'steer') {
106
+ try {
107
+ agent.steer?.(buildSteerMessage(doing));
108
+ steered = true;
109
+ }
110
+ catch (error) {
111
+ reason = `${reason}; steer failed: ${error instanceof Error ? error.message : String(error)}`;
112
+ }
113
+ }
114
+ // Log every decision: an empty log must mean "the event never fired",
115
+ // never "the answer was no".
83
116
  gateLog.push({
84
117
  at: new Date().toISOString(),
85
118
  sessionId: agentId,
86
119
  source,
120
+ mode: nudgeMode,
87
121
  nudge: decision.nudge,
88
- reason: decision.reason,
122
+ steered,
123
+ reason,
89
124
  doing,
90
125
  });
91
126
  if (gateLog.length > 20)
92
127
  gateLog.shift();
93
128
  };
94
129
  ctx.effect(() => events.on('agent/turn-stopping', (payload) => {
130
+ if (nudgeMode === 'off')
131
+ return;
95
132
  const event = payload;
96
133
  if (typeof event.agent?.id === 'string')
97
- evaluate(event.agent.id, 'turn-stopping');
134
+ evaluate(event.agent, 'turn-stopping');
98
135
  }));
99
- // Second observation point. `agent/status` → idle also marks a turn end, so
100
- // keeping both in the log tells us which one the runtime actually delivers —
101
- // and leaves the gate working if `turn-stopping` turns out not to reach a
102
- // root listener.
136
+ // Fallback close signal. It evaluates only when `turn-stopping` left activity
137
+ // unconsumed, so it never duplicates a decision — but it still catches the
138
+ // turn if the primary event is ever missing.
103
139
  ctx.effect(() => events.on('agent/status', (payload) => {
140
+ if (nudgeMode === 'off')
141
+ return;
104
142
  const event = payload;
105
143
  if (event.status !== 'idle')
106
144
  return;
107
- if (typeof event.agent?.id === 'string')
108
- evaluate(event.agent.id, 'status');
145
+ const agent = event.agent;
146
+ if (!agent || typeof agent.id !== 'string')
147
+ return;
148
+ const current = activity.get(agent.id);
149
+ if (!current || (!current.mutated && !current.checklistWrite))
150
+ return;
151
+ evaluate(agent, 'status');
109
152
  }));
110
153
  let loggedScope = false;
111
154
  ctx.inject(['systemPrompt'], (promptCtx) => {
@@ -161,7 +204,7 @@ export function apply(ctx, config = {}) {
161
204
  kind: 'exact',
162
205
  path: NUDGE_ROUTE,
163
206
  handler: (req, res) => {
164
- const body = Buffer.from(`${JSON.stringify({ mode: 'observe', entries: gateLog }, null, 2)}\n`, 'utf8');
207
+ const body = Buffer.from(`${JSON.stringify({ mode: nudgeMode, entries: gateLog }, null, 2)}\n`, 'utf8');
165
208
  res.writeHead(200, {
166
209
  'content-type': 'application/json; charset=utf-8',
167
210
  'content-length': String(body.byteLength),
@@ -24,5 +24,12 @@ export interface Config {
24
24
  pruneDays?: number;
25
25
  /** Extra templates merged over the built-in ones. */
26
26
  templates?: Record<string, string[]>;
27
+ /**
28
+ * What the turn-end gate does when it finds a stale checklist.
29
+ * - `steer` (default): put a message in front of the agent before the turn ends.
30
+ * - `observe`: record the decision, never interrupt.
31
+ * - `off`: do not evaluate at all.
32
+ */
33
+ nudge?: 'steer' | 'observe' | 'off';
27
34
  }
28
35
  export declare function apply(ctx: Context, config?: Config): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-checklists",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",