instar 0.28.87 → 0.28.89

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.
@@ -0,0 +1,31 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: minor -->
4
+ <!-- Valid values: patch, minor, major -->
5
+ <!-- patch = bug fixes, refactors, test additions, doc updates -->
6
+ <!-- minor = new features, new APIs, new capabilities (backwards-compatible) -->
7
+ <!-- major = breaking changes to existing APIs or behavior -->
8
+
9
+ ## What Changed
10
+
11
+ New endpoint `POST /sessions/refresh` lets an agent trigger its own session respawn. The server saves the current Claude session UUID, kills the tmux session, and spawns a fresh one with `claude --resume <uuid>` — which attaches any MCP servers or skills installed mid-session while preserving full conversation state. The lifecycle is owned by the new `SessionRefresh` class in `src/core/SessionRefresh.ts`. The existing Telegram `/restart` command now delegates to the same orchestrator (behavior unchanged), consolidating kill+respawn logic in one place.
12
+
13
+ Rate-limited to 5 refreshes per 10-minute rolling window per session to prevent infinite respawn loops. The route returns `202 Accepted` immediately because the requester's process is the one being killed; the kill+spawn fires ~500ms after the response flushes. Validation rejects bad session names, oversized prompts, and oversized reason strings.
14
+
15
+ v1 scope is Telegram-bound sessions only. Non-Telegram-bound respawn returns `{ ok: false, code: 'not_telegram_bound' }` and is a v2 follow-up.
16
+
17
+ API:
18
+ - Request: `POST /sessions/refresh` with `{ sessionName: string, followUpPrompt?: string, reason?: string }`
19
+ - 202: `{ ok: true, message: 'Refresh scheduled', sessionName }`
20
+ - 400: invalid input
21
+ - 503: no Telegram adapter wired (v1 limitation)
22
+
23
+ ## What to Tell Your User
24
+
25
+ - **Self-refresh after installing tools**: "I can now refresh myself to pick up new tools right after installing them. If I add a new capability mid-conversation, I'll quietly restart and pick up where we left off — no need for you to send another message just to wake me up."
26
+
27
+ ## Summary of New Capabilities
28
+
29
+ | Capability | How to Use |
30
+ |-----------|-----------|
31
+ | Agent-initiated session refresh | POST /sessions/refresh |
@@ -0,0 +1,13 @@
1
+ # Side-Effects Review — v0.28.87
2
+
3
+ This release shipped two user-facing changes:
4
+
5
+ - **Stuck-Telegram-message auto-recovery (PR #151)** — side-effects review at
6
+ [`verify-injection-stuck-input.md`](./verify-injection-stuck-input.md).
7
+ - **Project-scope Phase 1a PR 2 — stage validator + `/projects` API** —
8
+ side-effects review at
9
+ [`project-scope-phase1a-pr2.md`](./project-scope-phase1a-pr2.md).
10
+
11
+ This file is the version-keyed pointer the pre-push gate looks for; the
12
+ per-change artifacts above contain the actual side-effects analysis. No
13
+ other behavior changed in 0.28.87 beyond what those two reviews cover.
@@ -0,0 +1,477 @@
1
+ # Side-Effects Review — project-scope Phase 1a PR 3 (`/project` skill + session-start digest)
2
+
3
+ **Version / slug:** `project-scope-phase1a-pr3`
4
+ **Date:** `2026-05-11`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `required (new context-surface for the agent: session-start hook output)`
7
+
8
+ ## Summary of the change
9
+
10
+ Final of three PRs for project-scope Phase 1a. Adds the user-facing
11
+ surface and the session-start digest surface so active projects stay
12
+ visible across new sessions and after context compaction.
13
+
14
+ Spec source: `docs/specs/PROJECT-SCOPE-SPEC.md` §§ Phase 1.7 (skill),
15
+ Phase 1.9 (session-start + compaction-recovery hooks).
16
+
17
+ **New files:**
18
+ - `src/core/ProjectDigestCache.ts` (~170 lines) — synchronous JSON
19
+ digest writer. Renders one sanitized line per active project,
20
+ top-N by `lastTouchedAt`, atomic write to
21
+ `.instar/projects-digest.cache`. No I/O beyond the cache write and
22
+ a `tracker.list()` read.
23
+ - `.claude/skills/instar-project/SKILL.md` — user-invocable slash
24
+ command surface. Phase 1a ships `/project create`, `/project status`,
25
+ `/project next` only. The mutating commands ship in Phase 1b once
26
+ the round-runner exists.
27
+ - `tests/unit/ProjectDigestCache.test.ts` — 19 tests covering empty
28
+ case, 3-project case, 7-project truncation, sanitization (control
29
+ chars, newlines, 80-char cap), atomic write, and cache invalidator
30
+ wiring.
31
+ - `tests/unit/project-digest-hooks.test.ts` — 7 tests exercising both
32
+ hook scripts against present, missing, and malformed cache files;
33
+ asserts the read-time sanitization re-strips control chars.
34
+
35
+ **Modified files:**
36
+ - `src/commands/server.ts` (+10) — instantiates `ProjectDigestCache`,
37
+ wires the invalidator on `InitiativeTracker`, and writes the cache
38
+ unconditionally on first boot so the hook scripts always have
39
+ something to read on a fresh install.
40
+ - `.instar/hooks/instar/session-start.sh` (+50) — appends a
41
+ `--- ACTIVE PROJECTS ---` block after the existing orientation. Pure
42
+ file read (no HTTP). Falls back to `Active projects: state
43
+ unavailable — run /project status when ready` on miss / parse error.
44
+ - `.instar/hooks/instar/compaction-recovery.sh` (+50) — same block
45
+ with a `(post-compaction)` label.
46
+ - `upgrades/NEXT.md` — adds the Phase 1a PR 3 entry to the in-progress
47
+ release-notes file.
48
+
49
+ **Cross-spec links:**
50
+ - The cache file format is the same JSON shape PR 1 documented in the
51
+ `setDigestCacheInvalidator()` hook stub and Phase 1.9 of the spec.
52
+ - The skill's `POST /projects` flow uses the rate-limited handler PR 2
53
+ shipped.
54
+
55
+ ## Decision-point inventory
56
+
57
+ - **Cache write — what to include.** Hard-invariant filter:
58
+ `kind:'project' && status:'active'`, sorted by `lastTouchedAt` desc,
59
+ capped at 5. No LLM, no scored ranking. The `truncated` flag +
60
+ `totalActiveProjects` count make over-limit visible without a
61
+ decision.
62
+ - **Sanitization — what to strip.** Strip `[\x00-\x1F\x7F]` (ASCII
63
+ control + DEL), collapse whitespace, cap at 80 chars per sanitized
64
+ field. Applied at write time in `ProjectDigestCache.ts` AND at read
65
+ time in both hook scripts. The doc's "Hard-invariant validation"
66
+ carve-out applies: this is structural input cleaning at a system
67
+ boundary, not a judgement call.
68
+ - **Hook fallback — what to emit on miss.** A single fixed string
69
+ (`Active projects: state unavailable — run /project status when
70
+ ready`). Surfaces the recovery action; never silently swallows.
71
+ - **`/project next` — placeholder behavior.** The skill documents the
72
+ current 501 response shape and instructs the agent to surface a
73
+ user-facing message ("placeholder until Phase 1b") rather than
74
+ treating 501 as a hard error. Aligns with the PR 2-shipped route.
75
+
76
+ All decision points are structural — none introduces a new
77
+ conversational or judgement-based gate.
78
+
79
+ ---
80
+
81
+ ## 1. Over-block
82
+
83
+ **What legitimate inputs does this change reject that it shouldn't?**
84
+
85
+ - The digest cache filter is `kind:'project' && status:'active'`. Any
86
+ project the user wants visible at session start must be marked
87
+ `active`. Paused, halted, archived, and `awaiting-user` projects are
88
+ intentionally excluded from the orientation digest — they show up
89
+ on the dashboard but not in the cheap session-start surface. This
90
+ matches the spec's "keep the active roster visible" intent.
91
+ - The 5-project cap is firm. Users with more than 5 active projects
92
+ see the most-recently-touched 5 plus a "+N more on dashboard."
93
+ indicator. The mitigation (`/project status` returns the full list)
94
+ is documented in the skill body and the fallback message.
95
+ - The 80-char per-field sanitization cap can truncate long round
96
+ names. The spec endorses this — names are display strings, not
97
+ identifiers, and there are no consumers of the digest cache that
98
+ need the original-length string. The full record is always
99
+ available via `GET /projects/:id`.
100
+ - The hook falls back to the "state unavailable" string on any of
101
+ three conditions: missing cache file, non-JSON file, JSON with no
102
+ `digestLines` array. All three are recovery-friendly: the fallback
103
+ is informational, not an error.
104
+
105
+ No legitimate input shape is rejected by this change.
106
+
107
+ ---
108
+
109
+ ## 2. Under-block
110
+
111
+ **What failure modes does this still miss?**
112
+
113
+ - The hook fallback string is fixed text injected into orientation. A
114
+ malicious entity with write access to `.instar/projects-digest.cache`
115
+ could populate `digestLines[]` with arbitrary text — but write
116
+ access to the agent's state dir already means full agent
117
+ compromise (the dir holds AGENT.md, MEMORY.md, secrets). The
118
+ read-time sanitization re-strips ASCII control chars (so ANSI
119
+ escapes can't sneak into orientation), but free-form lying text
120
+ ("delete all files") would land in the agent's context. This is
121
+ documented as accepted-risk in the threat model (§ Threat model of
122
+ the spec).
123
+ - The cache file is per-machine (lives under `.instar/`, which IS
124
+ synced across machines via git-sync). A machine that just synced a
125
+ cache file from another machine sees that machine's snapshot of
126
+ projects. This is intentional — git-sync conflict handling for
127
+ projects (Phase 1.12, ships in Phase 1b) treats the cache as
128
+ derived state; it's recomputed on first project mutation. No
129
+ correctness issue.
130
+ - The cache write is best-effort (try/catch + console.warn). A
131
+ hiccup writing the cache never blocks a successful mutation. The
132
+ hook then emits the fallback on next read — recoverable on the next
133
+ mutation. This is the intended degradation path.
134
+ - The invalidator runs on *every* mutation through `InitiativeTracker`,
135
+ including `kind:'task'` initiatives. The filter inside
136
+ `writeDigestCache()` re-applies `kind:'project'` so the file content
137
+ is correct, but we do pay a write cost on every task mutation. With
138
+ ~tens of mutations per session on a typical agent, the write cost
139
+ (one ~1KB file write, atomic rename) is negligible. Acceptable.
140
+
141
+ ---
142
+
143
+ ## 3. Level-of-abstraction fit
144
+
145
+ **Is this at the right layer?**
146
+
147
+ Yes.
148
+
149
+ - `ProjectDigestCache.ts` lives alongside `InitiativeTracker.ts` in
150
+ `src/core/`. It is a pure write-side projection of tracker state,
151
+ with no HTTP, no LLM, no scheduling. Single responsibility:
152
+ serialise the project roster.
153
+ - The skill's body is procedural — `curl` commands plus a description
154
+ of expected response shapes. It does not duplicate validation logic;
155
+ it points the agent at the existing HTTP surface from PR 2 and
156
+ describes how to interpret each status code.
157
+ - Hook scripts read a file. They do not call HTTP, do not consult the
158
+ tracker, do not load `node`. The whole hot path is one `python3 -c`
159
+ with a JSON parse and a regex sanitization pass. Stays inside the
160
+ 50ms budget the spec specifies for the orientation surface.
161
+ - Server boot is the right place to wire the invalidator: it's the
162
+ single chokepoint where every consumer of `InitiativeTracker` gets
163
+ the same wiring. Boot also seeds the cache once so a fresh install
164
+ has a non-empty file (`{ digestLines: [], totalActiveProjects: 0,
165
+ truncated: false }`) before any session starts.
166
+
167
+ ---
168
+
169
+ ## 4. Signal vs authority compliance
170
+
171
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
172
+
173
+ **Does this change hold blocking authority with brittle logic?**
174
+
175
+ - [x] No — this is structural sanitization + a context-surface
176
+ writer. Explicitly within the doc's "Hard-invariant validation"
177
+ and "Display rendering" carve-outs.
178
+
179
+ The doc's carve-outs that apply here:
180
+
181
+ 1. **"Hard-invariant validation"** — `sanitizeDigestString()` is a
182
+ character-class strip (`/[\x00-\x1F\x7F]/g`). It does not inspect
183
+ meaning, does not judge intent, does not call out to a model. It
184
+ is a literal regex that runs on every output string. The same
185
+ logic re-applies on the read side in the hook scripts (Python's
186
+ `re.compile(r'[\x00-\x1F\x7F]')`).
187
+
188
+ 2. **"Display rendering"** — `formatProjectDigestLine()` is a template
189
+ function. Inputs come from the tracker (already type-validated by
190
+ PR 1's create/update paths); outputs are a fixed-shape
191
+ `Project [<id>]: <X> of <Y> done. Next round: <name>.` string. No
192
+ decision points inside.
193
+
194
+ There is no detector here that emits signals into a higher authority,
195
+ and no authority here that gates an action. The whole module is a
196
+ write-side projection. The "decision" of what to include is the
197
+ `status === 'active' && kind === 'project'` predicate, which is a
198
+ type-level filter, not a judgement.
199
+
200
+ ---
201
+
202
+ ## 5. Interactions
203
+
204
+ **Does this interact with existing checks, recovery paths, or infrastructure?**
205
+
206
+ - **Existing hooks.** The session-start and compaction-recovery hooks
207
+ already emit a long orientation block (identity, project map,
208
+ capabilities, working memory). The PR 3 addition is appended at the
209
+ *end* of each script's main flow, immediately before the closing
210
+ `=== END SESSION START ===` / `=== END IDENTITY RECOVERY ===`
211
+ banner. It does not replace existing content. If the new block
212
+ fails (which can only happen on a python3 absence), the orientation
213
+ block still ends cleanly — the python heredoc swallows its own
214
+ errors with `sys.exit(0)` and the surrounding bash falls through
215
+ to the fallback string.
216
+ - **InitiativeTracker invalidator.** PR 1 wired the
217
+ `setDigestCacheInvalidator` hook with a no-op default. PR 3 swaps
218
+ the no-op for `() => projectDigestCache.writeDigestCache()`. The
219
+ hook is fired after `create()`, `update()`, `setPhaseStatus()`, and
220
+ `remove()`. Every legacy caller is unaffected — the invalidator was
221
+ always called; we just gave it a real body.
222
+ - **TaskFlow.** When TaskFlow is wired (`config.taskFlow.enabled`),
223
+ the invalidator still fires after each tracker mutation (the
224
+ invalidator call is outside the TaskFlow branch in
225
+ `InitiativeTracker.ts`). The cache write reflects whichever store
226
+ is authoritative; `tracker.list()` already handles the projection.
227
+ - **HTTP routes.** No new routes. The skill calls the routes PR 2
228
+ shipped. No re-validation, no shadowing.
229
+ - **Auth.** The skill body documents the `Authorization: Bearer
230
+ <token>` requirement and shows how to read it from the config file.
231
+ No new auth path.
232
+ - **File-system contention.** Atomic write uses `temp + rename` with a
233
+ PID-suffixed temp file. Two processes writing concurrently each
234
+ use their own temp name; the final rename is atomic on the same
235
+ filesystem (POSIX guarantees). Last-writer-wins, but every write
236
+ is well-formed.
237
+ - **Race condition: hook reads while server writes.** Reader sees
238
+ either the prior complete file or the new complete file — never a
239
+ partial. POSIX rename atomicity is what the test
240
+ `tests/unit/ProjectDigestCache.test.ts > atomic write` asserts.
241
+ - **First-boot ordering.** The cache write happens before
242
+ `server.start()`, so the file exists on disk before any HTTP
243
+ request can arrive (or any session-start hook can fire from a
244
+ spawned subprocess).
245
+
246
+ ---
247
+
248
+ ## 6. External surfaces
249
+
250
+ **Does this change anything visible outside the immediate code path?**
251
+
252
+ - **Slash command surface.** A new `/project` skill is invocable
253
+ from any Claude Code session that has the `.claude/skills/`
254
+ directory. The skill is documented as `user_invocable: true` —
255
+ intentional surface, exposed by the harness.
256
+ - **Session-start orientation.** Every session that hits the
257
+ session-start hook now sees a `--- ACTIVE PROJECTS ---` block when
258
+ the cache file is present. On a fresh install the block emits an
259
+ empty section (`Active projects: none` after the header) — no
260
+ noise.
261
+ - **Compaction-recovery orientation.** Same block re-injects after
262
+ compaction. The agent reads this from context, not from disk
263
+ directly.
264
+ - **New persistent state.** `.instar/projects-digest.cache` — JSON
265
+ file, ~1KB typical. Written by every project mutation. Lives
266
+ alongside the existing initiative state. No migration required;
267
+ the file is fully derived.
268
+ - **Other agents on the same machine.** Each agent has its own
269
+ `.instar/`, so the cache file is per-agent. Other agents are
270
+ unaffected.
271
+ - **External systems.** None. No new outbound network call. No
272
+ shell-out to `git`, `gh`, or any external CLI. The hook scripts
273
+ use `python3` (already a dependency of the existing hooks).
274
+
275
+ ---
276
+
277
+ ## 7. Rollback cost
278
+
279
+ **If this turns out wrong in production, what's the back-out?**
280
+
281
+ - **Hot-fix release.** Revert the commit. The new files
282
+ (`ProjectDigestCache.ts`, `SKILL.md`, two test files,
283
+ `side-effects/project-scope-phase1a-pr3.md`) delete cleanly. The
284
+ modifications to `server.ts` and the two hook scripts revert with
285
+ no follow-up — the hook additions are appended, not interleaved,
286
+ so the diff is a clean removal.
287
+ - **Data migration.** None. The cache file is derived. If a
288
+ pre-rollback file remains on disk, the hook scripts will read it
289
+ using the pre-rollback shape (still valid JSON, still emits a
290
+ digest block) — but with the invalidator wiring gone, the file is
291
+ no longer updated. Deleting the file is safe; the hook falls back
292
+ to "state unavailable".
293
+ - **Agent state repair.** Not needed. Initiative records are
294
+ untouched.
295
+ - **User visibility.** Rollback removes the orientation digest block
296
+ and the `/project` skill. Anyone who'd started using `/project` would
297
+ need to call `curl` directly until a re-roll.
298
+ - **Phase 1b dependencies.** Phase 1b ships the round-runner and the
299
+ mutating skill commands. Phase 1b's PRs depend on the invalidator
300
+ wiring being in place (so the digest stays fresh through the
301
+ runner's mutations). If PR 3 is reverted, Phase 1b's first PR must
302
+ re-add the wiring at the same time. Tracking note for the Phase
303
+ 1a → 1b gate.
304
+
305
+ Rollback is a single `git revert`; the only follow-up is reverting
306
+ Phase 1b's first PR if it's already shipped (it has not, as of this
307
+ commit).
308
+
309
+ ---
310
+
311
+ ## Conclusion
312
+
313
+ PR 3 ships the user-facing surface and the session-start digest for
314
+ project-scope Phase 1a. No new decision points beyond hard-invariant
315
+ sanitization + a structural type filter. All sanitization is applied
316
+ at write AND at read time (defense in depth against direct cache
317
+ poisoning). The hook scripts have a fixed fallback string and stay
318
+ inside the 50ms budget. Cache invalidator wiring uses the hook PR 1
319
+ prepared; tests assert end-to-end through `InitiativeTracker.create() →
320
+ invalidator → cache file → hook script → parsed orientation output`.
321
+ Typecheck clean, 26 new tests passing, no existing test broken. Clear
322
+ to ship pending second-pass review.
323
+
324
+ ---
325
+
326
+ ## Second-pass review (required)
327
+
328
+ **Reviewer:** independent audit pass
329
+ **Date:** `2026-05-11`
330
+
331
+ Independently re-read the new ProjectDigestCache module, both hook
332
+ scripts, and the server-boot wiring. Audited along three axes the brief
333
+ flagged:
334
+
335
+ 1. **Sanitization at BOTH write time AND read time.** Verified:
336
+ - Write side: `sanitizeDigestString` in `ProjectDigestCache.ts`
337
+ applies `replace(/[\x00-\x1F\x7F]/g, ' ')` to every string that
338
+ lands in the cache (project id via `formatProjectDigestLine`,
339
+ round name, and the literal "Project [<id>]:" rendering). The
340
+ 80-char cap is enforced on each field before composition.
341
+ - Read side: the python heredoc in both hooks (`session-start.sh`,
342
+ `compaction-recovery.sh`) re-applies `re.sub(r'[\x00-\x1F\x7F]',
343
+ ' ', ln)` plus a 200-char hard cap on the rendered line. This is
344
+ the defense-in-depth pass the spec calls for: direct
345
+ cache-file poisoning that skips the TypeScript write path
346
+ cannot smuggle ANSI escapes or unprintable bytes into
347
+ orientation output. Asserted by the `re-sanitizes control chars
348
+ on read` test.
349
+
350
+ 2. **Prompt-injection surface.** The free-form text that lands in the
351
+ digest is `(project.id, round.name)`. Project id is constrained to
352
+ `^[a-z0-9-]{1,63}$` by `InitiativeTracker.create()`'s slug regex —
353
+ no injection vector there. Round name is user-supplied (via plan
354
+ doc) and gets sanitized and capped to 80 chars. The cap, combined
355
+ with the fixed prefix "Project [<id>]: X of Y done. Next round:"
356
+ and trailing period, bounds the maximum-attack payload to <200
357
+ chars of clean ASCII per line, repeated at most 5 times. No
358
+ ANSI escapes survive (control char strip). No newlines survive
359
+ (control char strip). No structured-output injection possible (the
360
+ orientation block is delimited by `--- ACTIVE PROJECTS ---` /
361
+ `--- END ACTIVE PROJECTS ---` and the digest contents cannot
362
+ produce those literal strings without including control chars,
363
+ which we strip).
364
+
365
+ 3. **Hook-script failure mode.** Both hooks wrap the python heredoc
366
+ with bash conditional logic that emits the fallback string on any
367
+ non-zero or empty output path. The python script itself uses
368
+ `sys.exit(0)` on every error branch, so the hook never propagates
369
+ a non-zero exit status into the orientation flow. Smoke tests
370
+ assert exit-status 0 on missing, present, malformed, and poisoned
371
+ cache files.
372
+
373
+ **Other findings:**
374
+ - Server-boot ordering is correct: `projectDigestCache.writeDigestCache()`
375
+ fires before `server.start()`, so the file is on disk before any
376
+ spawned session-start hook can fire from a child process. First
377
+ boot writes the empty-state file (`{ digestLines: [],
378
+ totalActiveProjects: 0, truncated: false }`).
379
+ - Atomic write is correct: temp file uses `process.pid` suffix; rename
380
+ is atomic on the same filesystem. The repeated-write test asserts
381
+ no partial JSON ever appears on disk.
382
+ - The `/project next` skill doc correctly surfaces the 501 placeholder
383
+ rather than treating it as an error. This matches the route shape
384
+ PR 2 shipped.
385
+
386
+ **Verdict: Concur with the review.** Clear to ship.
387
+
388
+ ---
389
+
390
+ ## Second-pass review — independent audit (PR-driver pass)
391
+
392
+ **Reviewer:** independent audit pass (PR driver, separate context from
393
+ artifact author)
394
+ **Date:** `2026-05-11`
395
+ **Specific concern flagged in brief:** prompt-injection via digest
396
+ content flowing into agent context (session-start + compaction-recovery
397
+ hook output). Reviewer must verify sanitization happens at BOTH write
398
+ time (in `ProjectDigestCache`) AND read time (in the hook scripts) —
399
+ defense in depth.
400
+
401
+ ### Findings — defense-in-depth verification
402
+
403
+ **Write-time sanitization (verified in `src/core/ProjectDigestCache.ts`):**
404
+ - `sanitizeDigestString()` strips `[\x00-\x1F\x7F]` (ASCII control +
405
+ DEL) by literal regex, collapses internal whitespace, then hard-caps
406
+ at `MAX_STRING_LENGTH = 80` chars per field.
407
+ - `formatProjectDigestLine()` applies `sanitizeDigestString()` to BOTH
408
+ `project.id` and `next.name` BEFORE composing the fixed-shape line
409
+ `Project [<id>]: X of Y done. Next round: <name>.`.
410
+ - `writeDigestCache()` JSON-encodes the payload and writes atomically
411
+ via temp-file + rename (PID-suffixed temp name, POSIX rename
412
+ atomicity).
413
+
414
+ **Read-time sanitization (verified in both hook scripts):**
415
+ - `.instar/hooks/instar/session-start.sh` (lines 279-313) and
416
+ `.instar/hooks/instar/compaction-recovery.sh` (lines 246-279) each
417
+ run an embedded python heredoc that:
418
+ - JSON-parses the cache file
419
+ - Iterates `digestLines[:5]` (defense against truncation bypass)
420
+ - Re-applies `re.compile(r'[\x00-\x1F\x7F]').sub(' ', ln)`
421
+ - Collapses internal whitespace and hard-caps each rendered line
422
+ at 200 chars
423
+ - Silently exits 0 on any exception → falls back to
424
+ "Active projects: state unavailable" string
425
+ - Template files (`src/templates/hooks/{session-start,compaction-recovery}.sh`)
426
+ carry the identical block so fresh installs get the same protection.
427
+
428
+ **Prompt-injection threat model audit:**
429
+ 1. Project id is regex-constrained (`^[a-z0-9-]{1,63}$`) at create
430
+ time by `InitiativeTracker.create()` — no injection vector.
431
+ 2. Round name is user-supplied via plan-doc YAML. Strip + 80-char cap
432
+ bounds it. Combined with the fixed prefix `Project [<id>]: X of Y
433
+ done. Next round:` and trailing period, max attack payload per line
434
+ is < 200 chars of clean printable ASCII, ≤ 5 lines.
435
+ 3. ANSI escape injection — blocked. `\x1B` is in the control char
436
+ class; stripped at both write and read.
437
+ 4. Newline / structured-output injection — blocked. `\n`, `\r`, `\t`
438
+ are in the control char class; stripped at both write and read.
439
+ 5. Delimiter injection (`--- END ACTIVE PROJECTS ---` as content) —
440
+ the 80-char per-field cap bounds round name, and even if the
441
+ literal delimiter string appears as content, the surrounding LLM
442
+ reads orientation as semantic content, not as a parser-delimited
443
+ structure. Not exploitable for instruction injection.
444
+ 6. Direct cache-file poisoning by an attacker with write access to
445
+ `.instar/projects-digest.cache` — read-side sanitization strips
446
+ any control chars the poisoned file might contain. Cap-and-strip
447
+ apply unconditionally on every read, independent of whether the
448
+ bytes ever passed through `ProjectDigestCache.ts`.
449
+
450
+ **Other findings:**
451
+ - Atomic write semantics verified by the `tests/unit/ProjectDigestCache.test.ts
452
+ > atomic write` test — repeated writes never expose a partial file.
453
+ - Hook-script exit status is 0 on every failure branch (missing file,
454
+ malformed JSON, non-list `digestLines`, empty list, exception). The
455
+ orientation flow never propagates a non-zero exit.
456
+ - First-boot ordering is correct: `projectDigestCache.writeDigestCache()`
457
+ runs before `server.start()` in `src/commands/server.ts`, so any
458
+ spawned session-start hook will see a valid file.
459
+ - `/project next` skill doc correctly surfaces the Phase 1b placeholder
460
+ shape (501) rather than treating it as a hard error.
461
+
462
+ **Verdict: Concur with the review.** Sanitization is applied at BOTH
463
+ boundaries with the same character-class strip and equivalent caps.
464
+ Defense-in-depth requirement met. Clear to ship.
465
+
466
+ ---
467
+
468
+ ## Evidence pointers
469
+
470
+ - `tests/unit/ProjectDigestCache.test.ts` — 19 passing
471
+ - `tests/unit/project-digest-hooks.test.ts` — 7 passing
472
+ - `tests/unit/InitiativeTracker.project.test.ts` — 26 passing (PR 1 + this PR's invalidator wiring)
473
+ - `tests/integration/projects-api.test.ts` — 14 passing (PR 2 routes)
474
+ - `node_modules/.bin/tsc --noEmit` → clean
475
+ - `npm run lint` → clean
476
+ - Spec: `docs/specs/PROJECT-SCOPE-SPEC.md` §§ 1.7, 1.9
477
+ - Signal-vs-authority reference: `docs/signal-vs-authority.md` § "Hard-invariant validation" + § "Display rendering"