dsh-checklists 0.5.0 → 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,18 +67,20 @@ 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;
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;
78
+ ctx.effect(() => events.on('tools/result', (exec) => {
79
+ // `tools/result` is emitted as (execution, result); the execution carries
80
+ // the tool name and the calling agent.
81
+ const call = exec;
82
+ const toolName = call?.name;
83
+ const agentId = call?.agent?.id;
61
84
  if (typeof toolName !== 'string' || typeof agentId !== 'string')
62
85
  return;
63
86
  const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
@@ -67,11 +90,8 @@ export function apply(ctx, config = {}) {
67
90
  current.checklistWrite = true;
68
91
  activity.set(agentId, current);
69
92
  }));
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;
93
+ const evaluate = (agent, source) => {
94
+ const agentId = agent.id;
75
95
  const current = activity.get(agentId) ?? { mutated: false, checklistWrite: false };
76
96
  activity.set(agentId, { mutated: false, checklistWrite: false });
77
97
  const doc = store.peek(agentId);
@@ -80,17 +100,55 @@ export function apply(ctx, config = {}) {
80
100
  for (const list of doc.lists)
81
101
  doing += countState(list.items, 'doing');
82
102
  const decision = decideNudge(current, doing);
83
- if (!decision.nudge && process.env.DSH_CHECKLIST_DEBUG !== '1')
84
- return;
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".
85
116
  gateLog.push({
86
117
  at: new Date().toISOString(),
87
118
  sessionId: agentId,
119
+ source,
120
+ mode: nudgeMode,
88
121
  nudge: decision.nudge,
89
- reason: decision.reason,
122
+ steered,
123
+ reason,
90
124
  doing,
91
125
  });
92
126
  if (gateLog.length > 20)
93
127
  gateLog.shift();
128
+ };
129
+ ctx.effect(() => events.on('agent/turn-stopping', (payload) => {
130
+ if (nudgeMode === 'off')
131
+ return;
132
+ const event = payload;
133
+ if (typeof event.agent?.id === 'string')
134
+ evaluate(event.agent, 'turn-stopping');
135
+ }));
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.
139
+ ctx.effect(() => events.on('agent/status', (payload) => {
140
+ if (nudgeMode === 'off')
141
+ return;
142
+ const event = payload;
143
+ if (event.status !== 'idle')
144
+ return;
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');
94
152
  }));
95
153
  let loggedScope = false;
96
154
  ctx.inject(['systemPrompt'], (promptCtx) => {
@@ -146,7 +204,7 @@ export function apply(ctx, config = {}) {
146
204
  kind: 'exact',
147
205
  path: NUDGE_ROUTE,
148
206
  handler: (req, res) => {
149
- 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');
150
208
  res.writeHead(200, {
151
209
  'content-type': 'application/json; charset=utf-8',
152
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.0",
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",