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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +56 -17
- package/dist/commands/server.js.map +1 -1
- package/dist/core/ProjectDigestCache.d.ts +58 -0
- package/dist/core/ProjectDigestCache.d.ts.map +1 -0
- package/dist/core/ProjectDigestCache.js +146 -0
- package/dist/core/ProjectDigestCache.js.map +1 -0
- package/dist/core/SessionRefresh.d.ts +108 -0
- package/dist/core/SessionRefresh.d.ts.map +1 -0
- package/dist/core/SessionRefresh.js +157 -0
- package/dist/core/SessionRefresh.js.map +1 -0
- package/dist/server/AgentServer.d.ts +1 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +1 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +4 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +48 -0
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +58 -50
- package/src/templates/hooks/compaction-recovery.sh +50 -0
- package/src/templates/hooks/session-start.sh +50 -0
- package/upgrades/0.28.88.md +91 -0
- package/upgrades/0.28.89.md +31 -0
- package/upgrades/side-effects/0.28.87.md +13 -0
- package/upgrades/side-effects/project-scope-phase1a-pr3.md +477 -0
- package/upgrades/side-effects/session-self-refresh.md +382 -0
|
@@ -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"
|