claude-dev-env 7.1.0 → 7.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.
@@ -1,9 +1,12 @@
1
1
  # Session continuity companion
2
2
 
3
- Status: draft implementation. Claude and Codex adapters have repository-level
4
- contract tests. Installed-host activation, reload, and agent compliance remain
5
- unverified. Cursor automatic activation is unsupported by this implementation.
6
- All three hosts remain part of the requested outcome.
3
+ Status: draft implementation. Claude, Codex, and Cursor adapters have
4
+ repository-level contract tests. Installed-host activation, reload, and agent
5
+ compliance remain unverified.
6
+
7
+ Every supported host activates Poteto Mode automatically when a session starts
8
+ and when the host resumes the conversation after compaction. An explicit
9
+ invocation still works and still records the user's own scope.
7
10
 
8
11
  ## The persistence failure
9
12
 
@@ -12,6 +15,10 @@ lose its duration, later corrections, or the instruction to reload it. This
12
15
  companion saves user-scoped requirements under a stable host-session key and
13
16
  returns the current skill contents during supported recovery callbacks.
14
17
 
18
+ Automatic activation removes the first half of that failure. A session that
19
+ never carried an explicit invocation still starts with Poteto Mode loaded, so
20
+ the mode does not depend on the user retyping it after each restart.
21
+
15
22
  ## Ownership and setup
16
23
 
17
24
  Pstack owns `Poteto Mode`, directory `poteto-mode`. Its source description names
@@ -33,7 +40,8 @@ The existing selective `--only core` list does not include the companion in this
33
40
  draft. Use the full install above. Plugin-only installations need the same
34
41
  canonical installed skill before this setup can run.
35
42
 
36
- The setup defaults to `claude codex`. Pass either host name to configure it alone.
43
+ The setup defaults to `claude codex cursor`. Pass one or more host names to
44
+ configure that subset alone.
37
45
  It uses `bin/resolve-install-root.mjs`, including `CLAUDE_CONFIG_DIR` and
38
46
  `CODEX_HOME`. It merges only its own hook groups into Claude `settings.json` and
39
47
  Codex `hooks.json`, preserves other settings, writes a one-time backup before
@@ -51,10 +59,13 @@ that has not been tested. Use builds exposing the documented events below.
51
59
 
52
60
  | Host | Adapter input | Forms recognized by the companion |
53
61
  | --- | --- | --- |
62
+ | Claude Code | `SessionStart` with source `startup`, `resume`, `compact`, `clear`, or `fork` | Automatic session-scope activation, no user text needed |
54
63
  | Claude Code | `UserPromptExpansion`, `expansion_type=slash_command` | Existing `poteto-mode` and `pstack:poteto-mode` command names |
55
64
  | Claude Code | `UserPromptSubmit` | A leading `/poteto-mode` or `/pstack:poteto-mode`; exact natural forms below |
65
+ | Codex | `SessionStart` with source `startup`, `resume`, `compact`, or `clear` | Automatic session-scope activation, no user text needed |
56
66
  | Codex | `UserPromptSubmit` | A leading `$poteto-mode`, `$pstack:poteto-mode`, or `[$pstack:poteto-mode](path)` skill reference; exact natural forms below |
57
- | Cursor | No automatic adapter installed | The inspected prompt and lifecycle hooks do not establish the required timing |
67
+ | Cursor | `sessionStart` | Automatic session-scope activation, returned as `additional_context` |
68
+ | Cursor | `beforeSubmitPrompt` | The same leading command and natural forms; the prompt is never blocked |
58
69
 
59
70
  Natural forms include bare `poteto`, `Use Poteto Mode`, `Activate Poteto Mode`,
60
71
  `Invoke Poteto Mode`, and `Poteto Mode applies for this entire session`. Exact
@@ -65,6 +76,12 @@ Free-form style requests and host UI forms that omit the skill token from the
65
76
  prompt remain unverified. The adapter's recognition is not proof that every host
66
77
  resolves every form as a native skill invocation.
67
78
 
79
+ Automatic activation records one session-scope skill requirement carrying the
80
+ `automatic` flag. A later explicit invocation writes the entry again with the
81
+ user's own evidence quote and scope. An explicit deactivation stops automatic
82
+ recovery for the same host session; a Claude or Codex `clear` starts a fresh
83
+ automatic set under the reused session id.
84
+
68
85
  The companion does not define a `/potato-mode` alias or rename `poteto-mode`.
69
86
  Quotations, fenced examples, indented code, discussion, imported transcripts, and
70
87
  tool-result callbacks do not independently activate the companion. Subsequent
@@ -111,12 +128,14 @@ messages and source comparison snapshots and stay outside the repository.
111
128
 
112
129
  | Situation | Discovery and restoration |
113
130
  | --- | --- |
114
- | First recognized invocation | Hook creates the record, returns its exact path and read-back, and includes the complete companion instructions |
131
+ | Session start with no record | Hook creates an active record holding a session-scope Poteto Mode requirement and returns its path, read-back, and current source |
132
+ | First recognized invocation | Hook creates or updates the record, returns its exact path and read-back, and includes the complete companion instructions |
115
133
  | Later user prompt | Same session key; pending evidence is saved and the companion directs the agent to reconcile it before dependent work |
116
134
  | Claude/Codex startup or resume | `SessionStart` with the same id finds the active record and reads current active skill sources |
117
135
  | Claude/Codex compaction | `SessionStart` with source `compact` reloads the same record and sources |
118
- | New unrelated conversation | A new id has no record and inherits nothing |
119
- | Clear | The same-id record is deactivated; a new id starts empty |
136
+ | Cursor compaction | `preCompact` marks the record for reload; the next `postToolUse` returns the record and sources as `additional_context` |
137
+ | New unrelated conversation | A new id starts a fresh automatic set and inherits no task, rule, or pending message |
138
+ | Clear | The same-id record holds a fresh automatic set |
120
139
  | Explicit handoff | The source command binds a snapshot to a known destination host/id; its next supported start callback discovers that record |
121
140
  | Explicit deactivation | An inactive tombstone suppresses recovery until another explicit invocation creates a fresh active set |
122
141
 
@@ -136,15 +155,20 @@ host-truncated source in full before dependent work. Missing sources produce an
136
155
  explicit unavailable message. Changed sources include current contents and a
137
156
  bounded before/after comparison. Stored snapshots are for comparison only.
138
157
 
139
- Cursor's `beforeSubmitPrompt` returns `continue` and a blocked-user message, not
140
- agent context. Its `preToolUse` can send agent context by denying a pending tool,
141
- and `postToolUse` can inject context after a tool runs. That does not cover a
142
- reply-only invocation before dependent work. `sessionStart` is fire-and-forget
143
- when a composer conversation is created; `preCompact` cannot block or modify
144
- compaction. Startup reminders, first-tool-only gates, or stop-hook follow-ups
145
- would weaken the requested contract. This draft installs none of those as a
146
- substitute. Shared storage accepts a `cursor` namespace for explicit operations;
147
- that is not automatic Cursor support.
158
+ Cursor's documented `sessionStart` output carries `additional_context`, so the
159
+ Cursor adapter loads the record and the current sources when a composer
160
+ conversation is created. Cursor has no post-compaction event that returns agent
161
+ context: `preCompact` runs before compaction and returns only a user message.
162
+ The adapter therefore marks the record during `preCompact` and returns the
163
+ record and sources through the next `postToolUse`, whose documented output
164
+ includes `additional_context`. On Cursor the reload lands with the agent's first
165
+ tool result after compaction rather than before it, which is the earliest
166
+ documented delivery point.
167
+
168
+ Cursor's `beforeSubmitPrompt` output carries `continue` and a user message, not
169
+ agent context. The adapter uses it to save pending user evidence and to mark an
170
+ explicit invocation for the next reload. It always returns `continue: true`, so
171
+ a companion failure never blocks the user's prompt.
148
172
 
149
173
  Automatic prompt capture and source emission are code paths. Selecting actual
150
174
  user rules, checkpointing, loading shortened sources, and following instructions
@@ -153,7 +177,7 @@ compliance merely because a callback ran.
153
177
 
154
178
  ## Validation and remaining installed-host proof
155
179
 
156
- Run the repository contract tests:
180
+ Run the repository contract tests, which cover all three host adapters:
157
181
 
158
182
  ```sh
159
183
  node --test packages/claude-dev-env/.agents/skills/session-continuity/continuity.test.mjs \
@@ -177,10 +201,10 @@ real compaction and a same-conversation restart, supply no record path, and
177
201
  capture discovery plus source reload before dependent work. Repeat with a later
178
202
  correction, a new skill, changed/missing sources, simultaneous sessions,
179
203
  quoted-only mentions, explicit handoff, deactivation, and reactivation. Keep
180
- Poteto's source hash unchanged throughout. Cursor needs a supported pre-work
181
- loading mechanism before equivalent installed-host acceptance can be run.
204
+ Poteto's source hash unchanged throughout. On Cursor, also capture the tool call
205
+ whose `postToolUse` result carries the reload after a compaction.
182
206
 
183
- ## Primary references checked on 2026-09-06
207
+ ## Primary references checked on 2026-09-07
184
208
 
185
209
  - [Pstack Poteto Mode source](https://github.com/cursor/plugins/blob/main/pstack/skills/poteto-mode/SKILL.md)
186
210
  - [Claude Code hooks](https://code.claude.com/docs/en/hooks)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: session-continuity
3
- description: Preserve user-scoped task requirements and named skills in a host-session record. Loaded by the separate Poteto companion hook or explicitly invoked as session-continuity. triggers with poteto-mode, poteto, /poteto-mode, /poteto.
3
+ description: Preserve user-scoped task requirements and named skills in a host-session record, and activate Poteto Mode automatically at session start and after compaction. Loaded by the separate Poteto companion hook or explicitly invoked as session-continuity. triggers with poteto-mode, poteto, /poteto-mode, /poteto.
4
4
  ---
5
5
 
6
6
  # Session continuity
@@ -11,6 +11,16 @@ Preserve the user's instructions at their actual scope. Restore the saved record
11
11
  and the current skill sources before work that depends on them. Keep Poteto Mode
12
12
  and its invocation unchanged.
13
13
 
14
+ ## Automatic activation
15
+
16
+ Each supported host runs this companion when a session starts and when it
17
+ resumes after compaction. With no saved record, the hook writes one holding a
18
+ session-scope `pstack:poteto-mode` requirement marked `automatic`, then returns
19
+ the current Poteto Mode source. Treat that source as the mode the user asked for
20
+ at session scope. A later explicit invocation writes the entry again with
21
+ the user's own quote and scope. An explicit deactivation stops the automatic
22
+ recovery for that host session.
23
+
14
24
  ## On activation or recovery
15
25
 
16
26
  1. Immediately report the exact absolute record path printed by the hook.
@@ -102,13 +112,14 @@ authoritative location is restored or the user changes the requirement.
102
112
  `/session-continuity off` or `Deactivate session continuity.` deactivates the
103
113
  companion record. This does not change Poteto Mode. A later explicit Poteto or
104
114
  companion invocation starts a new active set in the same record location.
105
- Deactivated requirements stay out of recovery.
115
+ Deactivated requirements stay out of recovery, and automatic activation stays
116
+ off for that host session until an explicit invocation or a `clear` arrives.
106
117
 
107
118
  ## Verification limits
108
119
 
109
- Read `README.md` for setup and supported trigger forms. Claude and Codex have
110
- repository-tested hook adapters. Installed-host delivery, source loading, and
111
- agent compliance require separate live evidence. Cursor automatic activation
112
- and recovery are not implemented because the inspected prompt and lifecycle
113
- hooks do not establish the required pre-work delivery contract. A manual storage
114
- command is not evidence of automatic activation in any host.
120
+ Read `README.md` for setup and supported trigger forms. Claude, Codex, and
121
+ Cursor have repository-tested hook adapters. Installed-host delivery, source
122
+ loading, and agent compliance require separate live evidence. On Cursor the
123
+ post-compaction reload arrives with the first tool result after compaction,
124
+ because Cursor documents no post-compaction event that returns agent context.
125
+ A manual storage command is not evidence of automatic activation in any host.
@@ -7,6 +7,9 @@ const skillDirectory = dirname(fileURLToPath(import.meta.url));
7
7
  const hosts = new Set(['claude', 'codex', 'cursor']);
8
8
  const potetoNames = new Set(['poteto-mode', 'pstack:poteto-mode']);
9
9
  const companionNames = new Set(['session-continuity', 'claude-dev-env:session-continuity']);
10
+ const cursorEvents = new Set(['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse']);
11
+ const automaticQuote = 'Automatic activation configured in the installed host hook';
12
+ const automaticDuration = 'This host session, including its resumes and compactions';
10
13
  const digest = value => createHash('sha256').update(value).digest('hex');
11
14
  const encode = value => JSON.stringify(value, null, 2);
12
15
  const stateRoot = () => resolve(process.env.CDE_CONTINUITY_ROOT || join(skillDirectory, '..', '..', 'state', 'session-continuity'));
@@ -116,16 +119,31 @@ function sourceSnapshot(source) {
116
119
 
117
120
  function promptEvidence(payload) {
118
121
  const text = requireText(payload.prompt, 'user prompt');
119
- return { id: digest(`${payload.turn_id || ''}\0${text}`), text, authority: 'user-message-evidence' };
122
+ return { id: digest(`${payload.turn_id || payload.generation_id || ''}\0${text}`), text, authority: 'user-message-evidence' };
120
123
  }
121
124
 
122
- function activate(previous, host, session, payload, trigger) {
123
- const record = previous?.status === 'active' ? previous : {
125
+ function emptyRecord(host, session, payload) {
126
+ return {
124
127
  schema: 1, host, session_id: session, status: 'active', revision: 0,
125
- workspace: payload.cwd || '',
128
+ workspace: payload.cwd || (payload.workspace_roots || [])[0] || '',
126
129
  task: { id: randomUUID(), goal: '', boundaries: [], constraints: [], completion: [], completed: false },
127
130
  requirements: [], pending: [], checkpoint: { completed: [], remaining: [] },
128
131
  };
132
+ }
133
+
134
+ function automaticRecord(host, session, payload) {
135
+ const record = emptyRecord(host, session, payload);
136
+ record.requirements.push({
137
+ id: 'skill:pstack:poteto-mode', kind: 'skill', name: 'pstack:poteto-mode', active: true,
138
+ scope: 'session', task_id: null, duration: automaticDuration, automatic: true,
139
+ evidence: { prompt_id: null, quote: automaticQuote },
140
+ ...sourceSnapshot(potetoSource()),
141
+ });
142
+ return record;
143
+ }
144
+
145
+ function activate(previous, host, session, payload, trigger) {
146
+ const record = previous?.status === 'active' ? previous : emptyRecord(host, session, payload);
129
147
  if (record.task.completed) {
130
148
  record.requirements.forEach(requirement => { if (requirement.scope === 'task') requirement.active = false; });
131
149
  record.task = { id: randomUUID(), goal: '', boundaries: [], constraints: [], completion: [], completed: false };
@@ -141,7 +159,7 @@ function activate(previous, host, session, payload, trigger) {
141
159
  ? { source: current.source, sha256: current.sha256, comparison_text: current.comparison_text }
142
160
  : sourceSnapshot(selectedSource);
143
161
  const entry = {
144
- id, kind: 'skill', name: 'pstack:poteto-mode', active: true,
162
+ id, kind: 'skill', name: 'pstack:poteto-mode', active: true, automatic: false,
145
163
  scope, task_id: scope === 'task' ? record.task.id : null,
146
164
  duration: trigger.scope ? trigger.text : current?.duration || 'Current task, until completed or explicitly changed by the user',
147
165
  evidence: { prompt_id: evidence.id, quote: trigger.text },
@@ -200,8 +218,61 @@ function render(record, path, loadSources) {
200
218
  return sections.join('\n\n');
201
219
  }
202
220
 
221
+ const deactivationRequest = prompt => /^(?:[/$](?:claude-dev-env:)?session-continuity off|deactivate session continuity)[.!]?\s*$/i.test(prompt || '');
222
+
223
+ function sessionOpening(host, session, path, previous, payload, reset) {
224
+ if (!reset && previous) {
225
+ if (previous.status !== 'active') return {};
226
+ return { context: render(previous, path, true) };
227
+ }
228
+ try {
229
+ const saved = transact(host, session, () => automaticRecord(host, session, payload));
230
+ return { message: `Activated Poteto Mode for this session and read the record back: ${saved.path}`,
231
+ context: render(saved.record, saved.path, true) };
232
+ } catch (error) {
233
+ return { message: `Session continuity did not activate Poteto Mode automatically: ${error.message}` };
234
+ }
235
+ }
236
+
237
+ function cursorHook(payload) {
238
+ const event = payload.hook_event_name;
239
+ if (!cursorEvents.has(event) || payload.agent_id) return {};
240
+ const session = requireText(payload.conversation_id || payload.session_id, 'host session id');
241
+ const path = recordPath('cursor', session);
242
+ const previous = readRecord(path, 'cursor', session);
243
+ if (event === 'sessionStart') {
244
+ const opening = sessionOpening('cursor', session, path, previous, payload, false);
245
+ return { ...(opening.context ? { additional_context: opening.context } : {}), ...(opening.message ? { user_message: opening.message } : {}) };
246
+ }
247
+ if (event === 'preCompact') {
248
+ if (previous?.status !== 'active') return {};
249
+ transact('cursor', session, record => ({ ...record, reload_pending: true }));
250
+ return { user_message: 'Poteto Mode and the saved session record reload on the next tool result after this compaction.' };
251
+ }
252
+ if (event === 'postToolUse') {
253
+ if (previous?.status !== 'active' || !previous.reload_pending) return {};
254
+ const saved = transact('cursor', session, record => ({ ...record, reload_pending: false }));
255
+ return { additional_context: render(saved.record, saved.path, true) };
256
+ }
257
+ if (deactivationRequest(payload.prompt)) {
258
+ if (previous?.status !== 'active') return { continue: true };
259
+ const saved = transact('cursor', session, record => ({ ...record, status: 'inactive', reload_pending: false, pending: [] }));
260
+ return { continue: true, user_message: `Session continuity deactivated at ${saved.path}. Poteto Mode is unchanged.` };
261
+ }
262
+ const trigger = invocation(payload.prompt);
263
+ if (!trigger && previous?.status !== 'active') return { continue: true };
264
+ const saved = transact('cursor', session, record => {
265
+ if (trigger) return { ...activate(record, 'cursor', session, payload, trigger), reload_pending: true };
266
+ if (record?.status !== 'active') throw new Error('Record deactivated during prompt processing; retry');
267
+ const evidence = promptEvidence(payload);
268
+ if (!record.pending.some(requirement => requirement.id === evidence.id)) record.pending.push(evidence);
269
+ return record;
270
+ });
271
+ return { continue: true, user_message: `Session continuity recorded this message at ${saved.path}. Revision ${saved.record.revision}.` };
272
+ }
273
+
203
274
  function hook(host, payload) {
204
- if (host === 'cursor') throw new Error('Cursor automatic activation is unsupported. beforeSubmitPrompt cannot inject agent context; sessionStart is fire-and-forget and preCompact is observational. No automatic hooks installed.');
275
+ if (host === 'cursor') return cursorHook(payload);
205
276
  const event = payload.hook_event_name;
206
277
  if (payload.agent_id || (payload.role && payload.role !== 'user')) return {};
207
278
  const accepted = ['SessionStart', 'UserPromptSubmit'];
@@ -211,16 +282,13 @@ function hook(host, payload) {
211
282
  const path = recordPath(host, session);
212
283
  const previous = readRecord(path, host, session);
213
284
  if (event === 'SessionStart') {
214
- if (payload.source === 'clear' && previous?.status === 'active') {
215
- transact(host, session, record => ({ ...record, status: 'inactive', pending: [] }));
216
- return { systemMessage: `Session continuity ended by clear: ${path}` };
217
- }
218
285
  const sources = host === 'claude' ? ['startup', 'resume', 'compact', 'clear', 'fork'] : ['startup', 'resume', 'compact', 'clear'];
219
286
  if (!sources.includes(payload.source)) return {};
220
- if (!previous || previous.status !== 'active') return {};
221
- return { hookSpecificOutput: { hookEventName: event, additionalContext: render(previous, path, true) } };
287
+ const opening = sessionOpening(host, session, path, previous, payload, payload.source === 'clear');
288
+ return { ...(opening.message ? { systemMessage: opening.message } : {}),
289
+ ...(opening.context ? { hookSpecificOutput: { hookEventName: event, additionalContext: opening.context } } : {}) };
222
290
  }
223
- if (/^(?:[/$](?:claude-dev-env:)?session-continuity off|deactivate session continuity)[.!]?\s*$/i.test(payload.prompt || '')) {
291
+ if (deactivationRequest(payload.prompt)) {
224
292
  if (!previous || previous.status !== 'active') return {};
225
293
  const saved = transact(host, session, record => ({ ...record, status: 'inactive', pending: [] }));
226
294
  return { systemMessage: `Session continuity deactivated at ${saved.path}. Poteto Mode is unchanged.`,
@@ -364,12 +432,16 @@ try {
364
432
  } catch (error) {
365
433
  if (command === 'hook') {
366
434
  const message = `Session continuity failed: ${error.message}`;
367
- console.log(encode({
368
- ...(['UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent) ? { decision: 'block', reason: message } : {}),
369
- systemMessage: message,
370
- ...(['SessionStart', 'UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent)
371
- ? { hookSpecificOutput: { hookEventName: hookEvent, additionalContext: `${message}. Stop dependent work until recovery succeeds.` } } : {}),
372
- }));
435
+ const guidance = `${message}. Stop dependent work until recovery succeeds.`;
436
+ console.log(encode(cursorEvents.has(hookEvent)
437
+ ? { ...(hookEvent === 'beforeSubmitPrompt' ? { continue: true } : {}), user_message: message,
438
+ ...(['sessionStart', 'postToolUse'].includes(hookEvent) ? { additional_context: guidance } : {}) }
439
+ : {
440
+ ...(['UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent) ? { decision: 'block', reason: message } : {}),
441
+ systemMessage: message,
442
+ ...(['SessionStart', 'UserPromptSubmit', 'UserPromptExpansion'].includes(hookEvent)
443
+ ? { hookSpecificOutput: { hookEventName: hookEvent, additionalContext: guidance } } : {}),
444
+ }));
373
445
  } else console.error(error.message);
374
446
  process.exitCode = command === 'hook' ? 0 : 1;
375
447
  }
@@ -26,7 +26,7 @@ function fixture(t, host) {
26
26
  writeFileSync(second, secondText);
27
27
  const script = join(skill, 'continuity.mjs');
28
28
  const configPath = join(root, host === 'claude' ? 'settings.json' : 'hooks.json');
29
- writeFileSync(configPath, JSON.stringify({ hooks: continuityHookConfiguration(host, script) }));
29
+ writeFileSync(configPath, JSON.stringify({ ...(host === 'cursor' ? { version: 1 } : {}), hooks: continuityHookConfiguration(host, script) }));
30
30
  const env = { ...process.env };
31
31
  delete env.CDE_CONTINUITY_ROOT;
32
32
  delete env.CDE_POTETO_SOURCE;
@@ -37,11 +37,11 @@ function fixture(t, host) {
37
37
  return expectedStatus ? result.stderr : JSON.parse(result.stdout);
38
38
  };
39
39
  const fire = (event, session, fields = {}) => {
40
- const payload = { hook_event_name: event, session_id: session, cwd: root, ...fields };
40
+ const payload = { hook_event_name: event, cwd: root, ...(host === 'cursor' ? { conversation_id: session } : { session_id: session }), ...fields };
41
41
  const config = JSON.parse(readFileSync(configPath, 'utf8'));
42
42
  const groups = config.hooks[event] || [];
43
43
  const matching = groups.filter(group => !group.matcher || new RegExp(group.matcher).test(event === 'SessionStart' ? fields.source : fields.command_name));
44
- return matching.flatMap(group => group.hooks.map(handler => {
44
+ return matching.flatMap(group => (group.hooks || [group]).map(handler => {
45
45
  const result = spawnSync(handler.command, { shell: true, input: JSON.stringify(payload), encoding: 'utf8', env });
46
46
  assert.equal(result.status, 0, result.stderr);
47
47
  return JSON.parse(result.stdout);
@@ -55,7 +55,6 @@ function fixture(t, host) {
55
55
  for (const host of ['claude', 'codex']) {
56
56
  test(`${host}: configured invocation emits complete instructions, writes and reads back, restores without a path`, t => {
57
57
  const f = fixture(t, host);
58
- assert.deepEqual(f.fire('SessionStart', 'session-a', { source: 'startup' }), [{}]);
59
58
  assert.equal(existsSync(f.pathFor('session-a')), false);
60
59
  const prefix = host === 'codex' ? '$pstack:poteto-mode' : '/pstack:poteto-mode';
61
60
  const prompt = `${prefix} for this entire session\nFinish the sample task. Keep changes local. Use second-skill for this task.`;
@@ -86,7 +85,9 @@ for (const host of ['claude', 'codex']) {
86
85
  assert.equal(readFileSync(f.poteto, 'utf8'), potetoText);
87
86
  record = f.read('session-a');
88
87
  assert.equal(record.pending.length, 0);
89
- assert.deepEqual(f.fire('SessionStart', 'unrelated-session', { source: 'startup' }), [{}]);
88
+ const unrelated = f.fire('SessionStart', 'unrelated-session', { source: 'startup' })[0].hookSpecificOutput.additionalContext;
89
+ assert.equal(unrelated.includes('build sample'), false);
90
+ assert.equal(unrelated.includes(secondText), false);
90
91
  });
91
92
 
92
93
  test(`${host}: quoting, discussion, tool output and subagent input do not activate`, t => {
@@ -247,12 +248,81 @@ test('a busy record blocks prompt processing, preserves state, and supports a sa
247
248
  assert.equal(f.read('busy').pending.length, 2);
248
249
  });
249
250
 
250
- test('clear deactivates a reused host id rather than inheriting an unrelated task', t => {
251
+ test('clear drops a reused host id rather than inheriting an unrelated task', t => {
251
252
  const f = fixture(t, 'claude');
252
- f.submit('clear', '/poteto-mode for this entire session');
253
+ f.submit('clear', '/poteto-mode for this entire session\nKeep the sample local.');
254
+ const before = f.read('clear');
253
255
  f.fire('SessionStart', 'clear', { source: 'clear' });
254
- assert.equal(f.read('clear').status, 'inactive');
255
- assert.deepEqual(f.fire('SessionStart', 'clear', { source: 'startup' }), [{}]);
256
+ const record = f.read('clear');
257
+ assert.equal(record.status, 'active');
258
+ assert.notEqual(record.task.id, before.task.id);
259
+ assert.deepEqual(record.pending, []);
260
+ assert.deepEqual(record.requirements.map(entry => entry.id), ['skill:pstack:poteto-mode']);
261
+ assert.equal(record.requirements[0].automatic, true);
262
+ });
263
+
264
+ for (const host of ['claude', 'codex']) {
265
+ test(`${host}: session start and compaction activate Poteto Mode with no explicit invocation`, t => {
266
+ const f = fixture(t, host);
267
+ const started = f.fire('SessionStart', 'auto', { source: 'startup' })[0];
268
+ assert.ok(started.systemMessage.includes(f.pathFor('auto')));
269
+ assert.ok(started.hookSpecificOutput.additionalContext.includes(potetoText));
270
+ const record = f.read('auto');
271
+ assert.equal(record.requirements.length, 1);
272
+ assert.equal(record.requirements[0].scope, 'session');
273
+ assert.equal(record.requirements[0].automatic, true);
274
+ assert.equal(record.requirements[0].source, f.poteto);
275
+ assert.deepEqual(record.pending, []);
276
+ for (const source of ['compact', 'resume']) {
277
+ const later = f.fire('SessionStart', 'auto', { source })[0].hookSpecificOutput.additionalContext;
278
+ assert.ok(later.includes(potetoText));
279
+ }
280
+ assert.equal(f.read('auto').revision, record.revision);
281
+ f.submit('auto', '/session-continuity off');
282
+ assert.deepEqual(f.fire('SessionStart', 'auto', { source: 'compact' }), [{}]);
283
+ assert.deepEqual(f.fire('SessionStart', 'auto', { source: 'startup' }), [{}]);
284
+ });
285
+
286
+ test(`${host}: an unavailable Poteto source stops automatic activation without writing a record`, t => {
287
+ const f = fixture(t, host);
288
+ writeFileSync(f.poteto, '---\nname: Something Else\n---\n');
289
+ const started = f.fire('SessionStart', 'broken', { source: 'startup' })[0];
290
+ assert.match(started.systemMessage, /did not activate Poteto Mode/);
291
+ assert.equal(existsSync(f.pathFor('broken')), false);
292
+ });
293
+ }
294
+
295
+ test('cursor: session start injects Poteto Mode and compaction reloads it on the next tool result', t => {
296
+ const f = fixture(t, 'cursor');
297
+ const started = f.fire('sessionStart', 'cursor-a', { session_id: 'composer-1', composer_mode: 'agent' })[0];
298
+ assert.ok(started.additional_context.includes(potetoText));
299
+ assert.equal(f.read('cursor-a').requirements[0].automatic, true);
300
+ assert.deepEqual(f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' }), [{}]);
301
+ const compacting = f.fire('preCompact', 'cursor-a', { trigger: 'auto' })[0];
302
+ assert.ok(compacting.user_message.includes('Poteto Mode'));
303
+ assert.equal(f.read('cursor-a').reload_pending, true);
304
+ const reloaded = f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' })[0];
305
+ assert.ok(reloaded.additional_context.includes(potetoText));
306
+ assert.ok(reloaded.additional_context.includes(f.pathFor('cursor-a')));
307
+ assert.equal(f.read('cursor-a').reload_pending, false);
308
+ assert.deepEqual(f.fire('postToolUse', 'cursor-a', { tool_name: 'Shell' }), [{}]);
309
+ });
310
+
311
+ test('cursor: a prompt records evidence, never blocks submission, and reloads named skills after a tool result', t => {
312
+ const f = fixture(t, 'cursor');
313
+ f.fire('sessionStart', 'cursor-b', { session_id: 'composer-2' });
314
+ const submitted = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: 'Finish the sample task.', generation_id: 'gen-1' })[0];
315
+ assert.equal(submitted.continue, true);
316
+ assert.equal(f.read('cursor-b').pending.length, 1);
317
+ const invoked = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: '/poteto-mode for this entire session', generation_id: 'gen-2' })[0];
318
+ assert.equal(invoked.continue, true);
319
+ assert.equal(f.read('cursor-b').reload_pending, true);
320
+ assert.ok(f.fire('postToolUse', 'cursor-b', { tool_name: 'Read' })[0].additional_context.includes(potetoText));
321
+ const off = f.fire('beforeSubmitPrompt', 'cursor-b', { prompt: '/session-continuity off', generation_id: 'gen-3' })[0];
322
+ assert.equal(off.continue, true);
323
+ assert.equal(f.read('cursor-b').status, 'inactive');
324
+ assert.deepEqual(f.fire('sessionStart', 'cursor-b', { session_id: 'composer-2' }), [{}]);
325
+ assert.deepEqual(f.fire('preCompact', 'cursor-b', { trigger: 'manual' }), [{}]);
256
326
  });
257
327
 
258
328
  test('missing host identity and corrupt records produce visible recovery failures', t => {
@@ -2,13 +2,15 @@ import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } fro
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ const supportedHosts = ['claude', 'codex', 'cursor'];
6
+
5
7
  export function continuityHookConfiguration(host, script) {
6
- if (host === 'cursor') {
7
- throw new Error('Cursor automatic activation is unsupported: beforeSubmitPrompt has no agent-context output, sessionStart is fire-and-forget, and preCompact is observational. No Cursor settings changed.');
8
- }
9
- if (!['claude', 'codex'].includes(host)) throw new Error(`Unsupported host ${host}`);
8
+ if (!supportedHosts.includes(host)) throw new Error(`Unsupported host ${host}`);
10
9
  if (/[\r\n"`$%]/.test(script)) throw new Error('Hook script path contains shell expansion characters');
11
10
  const command = `node "${script.replace(/\\/g, '/')}" hook ${host}`;
11
+ if (host === 'cursor') {
12
+ return Object.fromEntries(['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse'].map(event => [event, [{ command }]]));
13
+ }
12
14
  const events = host === 'claude' ? ['UserPromptSubmit', 'UserPromptExpansion', 'SessionStart'] : ['UserPromptSubmit', 'SessionStart'];
13
15
  return Object.fromEntries(events.map(event => [event, [{
14
16
  ...(event === 'SessionStart' ? { matcher: host === 'claude' ? 'startup|resume|compact|clear|fork' : 'startup|resume|compact|clear' } : {}),
@@ -20,19 +22,22 @@ export function continuityHookConfiguration(host, script) {
20
22
  export function mergeContinuityHooks(existing, host, script) {
21
23
  const additions = continuityHookConfiguration(host, script);
22
24
  const mergedConfiguration = structuredClone(existing);
25
+ if (host === 'cursor') mergedConfiguration.version = 1;
23
26
  mergedConfiguration.hooks ||= {};
24
27
  for (const [event, groups] of Object.entries(additions)) {
25
28
  const current = mergedConfiguration.hooks[event] || [];
26
- for (const group of current) {
27
- for (const hook of group.hooks || []) {
28
- if (hook.command?.includes('/session-continuity/continuity.mjs') && hook.command !== groups[0].hooks[0].command) {
29
+ const command = host === 'cursor' ? groups[0].command : groups[0].hooks[0].command;
30
+ for (const entry of current) {
31
+ for (const hook of entry.hooks || [entry]) {
32
+ if (hook.command?.includes('/session-continuity/continuity.mjs') && hook.command !== command) {
29
33
  throw new Error('Another continuity installation owns this host config. Select its profile or remove that installation explicitly.');
30
34
  }
31
35
  }
32
36
  }
33
- const kept = current.flatMap(group => {
34
- const hooks = (group.hooks || []).filter(hook => hook.command !== groups[0].hooks[0].command);
35
- return hooks.length ? [{ ...group, hooks }] : [];
37
+ const kept = current.flatMap(entry => {
38
+ if (host === 'cursor') return entry.command === command ? [] : [entry];
39
+ const hooks = (entry.hooks || []).filter(hook => hook.command !== command);
40
+ return hooks.length ? [{ ...entry, hooks }] : [];
36
41
  });
37
42
  mergedConfiguration.hooks[event] = kept.concat(groups);
38
43
  }
@@ -41,15 +46,20 @@ export function mergeContinuityHooks(existing, host, script) {
41
46
 
42
47
  async function main() {
43
48
  const requested = process.argv.slice(2);
44
- const selected = requested.length ? requested : ['claude', 'codex'];
45
- if (selected.some(host => !['claude', 'codex'].includes(host))) continuityHookConfiguration(selected.find(host => !['claude', 'codex'].includes(host)), '');
49
+ const selected = requested.length ? requested : supportedHosts;
50
+ const unsupported = selected.find(host => !supportedHosts.includes(host));
51
+ if (unsupported) throw new Error(`Unsupported host ${unsupported}`);
46
52
  const { resolveInstallRoot } = await import('./resolve-install-root.mjs');
47
53
  const roots = resolveInstallRoot();
48
54
  const script = join(roots.skillsInstallDirectory, 'session-continuity', 'continuity.mjs');
49
55
  if (!existsSync(script) || !existsSync(join(dirname(script), 'SKILL.md'))) {
50
56
  throw new Error('Run the full claude-dev-env installer from this checkout first so the companion is in the canonical agents home.');
51
57
  }
52
- const paths = { claude: join(roots.managedRoot, 'settings.json'), codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json') };
58
+ const paths = {
59
+ claude: join(roots.managedRoot, 'settings.json'),
60
+ codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json'),
61
+ cursor: join(roots.cursorInstallDirectory, 'hooks.json'),
62
+ };
53
63
  const plans = selected.map(host => {
54
64
  const path = paths[host];
55
65
  if (!existsSync(dirname(path))) throw new Error(`Host config directory is absent: ${dirname(path)}`);
@@ -70,7 +80,7 @@ async function main() {
70
80
  if (readFileSync(plan.path, 'utf8') !== plan.content) throw new Error(`Config read-back mismatch: ${plan.path}`);
71
81
  console.log(`Configured and read back: ${plan.path}`);
72
82
  }
73
- console.log('Review and trust the new hooks in each host. Cursor remains unsupported; no Cursor settings changed.');
83
+ console.log('Review and trust the new hooks in each host.');
74
84
  }
75
85
 
76
86
  if (process.argv[1] && resolve(realpathSync(process.argv[1])) === fileURLToPath(import.meta.url)) {
@@ -14,11 +14,24 @@ test('setup preserves other hooks and settings, is idempotent, and rejects compe
14
14
  assert.throws(() => continuityHookConfiguration('claude', '/path/with$expansion/continuity.mjs'), /expansion/);
15
15
  });
16
16
 
17
- test('Cursor setup refuses an unsupported automatic activation claim without changing config', () => {
18
- const existing = { version: 1, hooks: { beforeSubmitPrompt: [{ command: 'existing' }] } };
19
- const before = JSON.stringify(existing);
20
- assert.throws(() => mergeContinuityHooks(existing, 'cursor', '/skill/continuity.mjs'), /beforeSubmitPrompt has no agent-context output/);
21
- assert.equal(JSON.stringify(existing), before);
17
+ test('Cursor setup keeps other hooks, sets the file version, and stays idempotent', () => {
18
+ const existing = { hooks: { beforeShellExecution: [{ command: 'existing' }] } };
19
+ const script = '/home/jon/.agents/skills/session-continuity/continuity.mjs';
20
+ const first = mergeContinuityHooks(existing, 'cursor', script);
21
+ assert.equal(first.version, 1);
22
+ assert.equal(first.hooks.beforeShellExecution[0].command, 'existing');
23
+ assert.deepEqual(mergeContinuityHooks(first, 'cursor', script), first);
24
+ assert.equal(existing.hooks.beforeShellExecution.length, 1);
25
+ assert.throws(() => mergeContinuityHooks(first, 'cursor', '/other/session-continuity/continuity.mjs'), /Another continuity installation/);
26
+ });
27
+
28
+ test('Cursor setup registers only documented context-carrying events', () => {
29
+ const configuration = continuityHookConfiguration('cursor', '/home/jon/.agents/skills/session-continuity/continuity.mjs');
30
+ assert.deepEqual(Object.keys(configuration), ['sessionStart', 'beforeSubmitPrompt', 'preCompact', 'postToolUse']);
31
+ for (const entries of Object.values(configuration)) {
32
+ assert.equal(entries.length, 1);
33
+ assert.equal(entries[0].command, 'node "/home/jon/.agents/skills/session-continuity/continuity.mjs" hook cursor');
34
+ }
22
35
  });
23
36
 
24
37
  for (const host of ['claude', 'codex']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {