@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.10

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,66 @@
1
+ #!/usr/bin/env node
2
+ import y from"node:fs";import it from"node:net";import W from"node:path";import C from"node:fs";import P from"node:path";import{spawn as Z,execFile as ut}from"node:child_process";import{createRequire as q}from"node:module";import{StringDecoder as Q}from"node:string_decoder";var wt=2*1024*1024;var gt=192*1024;var yt=64*1024;var tt=String.raw`
3
+ import os, sys, pty, select, struct, fcntl, termios, signal
4
+ cols, rows = int(sys.argv[1]), int(sys.argv[2])
5
+ argv = sys.argv[3:]
6
+ pid, fd = pty.fork()
7
+ if pid == 0:
8
+ try:
9
+ os.execvp(argv[0], argv)
10
+ finally:
11
+ os._exit(127)
12
+ def size(c, r):
13
+ try: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', r, c, 0, 0))
14
+ except OSError: pass
15
+ size(cols, rows)
16
+ def wall(f, b):
17
+ while b:
18
+ try: n = os.write(f, b)
19
+ except InterruptedError: continue
20
+ b = b[n:]
21
+ ins = [0, fd, 3]
22
+ ctl = b''
23
+ while True:
24
+ try: r = select.select(ins, [], [])[0]
25
+ except InterruptedError: continue
26
+ if fd in r:
27
+ try: d = os.read(fd, 65536)
28
+ except OSError: d = b''
29
+ if not d: break
30
+ wall(1, d)
31
+ if 0 in r:
32
+ d = os.read(0, 65536)
33
+ if not d:
34
+ ins.remove(0)
35
+ try: os.kill(pid, signal.SIGHUP)
36
+ except OSError: pass
37
+ else:
38
+ try: wall(fd, d)
39
+ except OSError: pass
40
+ if 3 in r:
41
+ d = os.read(3, 4096)
42
+ if not d: ins.remove(3)
43
+ ctl += d
44
+ while b'\n' in ctl:
45
+ line, ctl = ctl.split(b'\n', 1)
46
+ p = line.split()
47
+ try:
48
+ if p[0] == b'R': size(int(p[1]), int(p[2]))
49
+ elif p[0] == b'K': os.kill(pid, int(p[1]))
50
+ except (IndexError, ValueError, OSError): pass
51
+ st = os.waitpid(pid, 0)[1]
52
+ code = os.waitstatus_to_exitcode(st)
53
+ sys.exit(code if code >= 0 else 128 - code)
54
+ `;function et({platform:t=process.platform,exists:n=c=>{try{return C.statSync(c).isFile()}catch{return!1}},isDir:o=c=>{try{return C.statSync(c).isDirectory()}catch{return!1}}}={}){return(t==="darwin"?["/opt/homebrew/bin/python3","/usr/local/bin/python3",...o("/Library/Developer/CommandLineTools")||o("/Applications/Xcode.app")?["/usr/bin/python3"]:[]]:["/usr/bin/python3","/usr/local/bin/python3","/bin/python3"]).find(n)||null}function st(t){return{kind:"node-pty",spawn({file:n,args:o,cwd:c,env:a,cols:d,rows:e}){let r=t.spawn(n,o,{name:"xterm-256color",cols:d,rows:e,cwd:c,env:a});return{pid:r.pid,onData:s=>r.onData(s),onExit:s=>r.onExit(({exitCode:p,signal:u})=>s({code:p,signal:u||null})),write:s=>r.write(s),resize:(s,p)=>{try{r.resize(s,p)}catch{}},kill:(s="SIGHUP")=>{try{r.kill(s)}catch{}}}}}}function U(t,{spawnImpl:n=Z}={}){return{kind:"python",spawn({file:o,args:c,cwd:a,env:d,cols:e,rows:r}){let s=n(t,["-c",tt,String(e),String(r),o,...c],{cwd:a,env:d,stdio:["pipe","pipe","pipe","pipe"]}),p=new Q("utf8"),u=[],H=[],j=!1;s.stdout.on("data",i=>{let h=p.write(i);if(h)for(let b of u)b(h)}),s.stderr.on("data",i=>{let h=String(i);for(let b of u)b(h)});let A=s.stdio[3];for(let i of[s.stdin,A])i&&i.on("error",()=>{});let G=(i,h)=>{if(j)return;j=!0;let b=p.end();if(b)for(let F of u)F(b);for(let F of H)F({code:i??null,signal:h||null})};s.on("exit",(i,h)=>setTimeout(()=>G(i,h),20)),s.on("error",i=>{for(let h of u)h(`\r
55
+ [terminal] `+i.message+`\r
56
+ `);G(127,null)});let V={SIGHUP:1,SIGINT:2,SIGKILL:9,SIGTERM:15};return{pid:s.pid,onData:i=>u.push(i),onExit:i=>H.push(i),write:i=>{try{s.stdin.write(i)}catch{}},resize:(i,h)=>{try{A.write("R "+i+" "+h+`
57
+ `)}catch{}},kill:(i="SIGHUP")=>{try{A.write("K "+(V[i]||1)+`
58
+ `)}catch{}if(i==="SIGKILL")try{s.kill("SIGKILL")}catch{}}}}}}function nt(t,{platform:n=process.platform,arch:o=process.arch,stat:c=d=>C.statSync(d),chmod:a=(d,e)=>C.chmodSync(d,e)}={}){let d=[];for(let e of[P.join(t,"prebuilds",n+"-"+o,"spawn-helper"),P.join(t,"build","Release","spawn-helper")]){let r;try{r=c(e)}catch{continue}if(!(!r.isFile()||(r.mode&73)===73))try{a(e,r.mode&511|493),d.push(e)}catch{}}return d}function rt(t,n){if(!n)return t;let o=t;return{get kind(){return o.kind},spawn(c){try{return o.spawn(c)}catch(a){if(o!==t)throw a;return o=n(),o.spawn(c)}}}}function K({requireImpl:t=q(import.meta.url),python:n=et(),env:o=process.env}={}){let c=null;if(o.LEFTOFF_TERM_BACKEND!=="python")try{let a=t("node-pty");try{nt(P.dirname(t.resolve("node-pty/package.json")))}catch{}return rt(st(a),n?()=>U(n):null)}catch(a){c=a}return n?U(n):{kind:null,why:"No terminal backend: node-pty isn’t installed"+(c&&!/Cannot find module/.test(c.message)?" ("+String(c.message).split(`
59
+ `)[0]+")":"")+" and there’s no python3. Run `leftoff upgrade`, or install python3."}}var X=process.argv[2],l;try{l=JSON.parse(y.readFileSync(X,"utf8"))}catch(t){process.stderr.write("pty-host: no spec: "+t.message+`
60
+ `),process.exit(2)}try{y.rmSync(X,{force:!0})}catch{}var{id:_,dir:B}=l,v=W.join(B,_+".sock"),N=W.join(B,_+".json"),ot=l.bufferMax||2*1024*1024,E=t=>process.stderr.write(new Date().toISOString()+" "+_+" "+t+`
61
+ `);process.on("SIGHUP",()=>{});process.on("SIGPIPE",()=>{});var I=K({env:l.env||process.env});I.kind||(E("no backend: "+I.why),process.exit(3));var f;try{f=I.spawn({file:l.file,args:l.args||[],cwd:l.cwd,env:l.env||process.env,cols:l.cols||80,rows:l.rows||24})}catch(t){E("spawn failed: "+t.message),process.exit(4)}var k=[],M=0,x=0,m=!1,O=null,S=null,J=!1,w=l.cols||80,g=l.rows||24,L={v:1,id:_,hostPid:process.pid,pid:f.pid||null,cwd:l.cwd,createdAt:new Date().toISOString(),cols:w,rows:g,backend:I.kind,...l.meta||{}},D=()=>{try{let t=N+".tmp";y.writeFileSync(t,JSON.stringify({...L,cols:w,rows:g,exited:m,code:O}),{mode:384}),y.renameSync(t,N)}catch(t){E("meta: "+t.message)}},T=new Set,R=t=>{let n=JSON.stringify(t)+`
62
+ `;for(let o of T)if(o.ready)try{o.write(n)}catch{}},$=()=>[...T].filter(t=>t.ready).map(t=>({role:t.role,...t.info||{}}));f.onData(t=>{for(k.push(t),M+=t.length,x+=t.length;M>ot&&k.length>1;)M-=k[0].length,k.shift();R({t:"out",d:t,end:x})});var ct=null;f.onExit(({code:t,signal:n})=>{m=!0,O=t??null,S=n||null,E("program exited ("+O+(S?", "+S:"")+")"),D(),R({t:"exit",code:O,signal:S}),ct=setTimeout(Y,J?300:l.linger==null?3e4:l.linger)});function Y(){for(let t of T)try{t.end()}catch{}try{z.close()}catch{}for(let t of[v,N])try{y.rmSync(t,{force:!0})}catch{}setTimeout(()=>process.exit(0),50)}try{y.rmSync(v,{force:!0})}catch{}var z=it.createServer(t=>{t.setEncoding("utf8");let n="";T.add(t),t.on("data",c=>{n+=c;let a;for(;(a=n.indexOf(`
63
+ `))>=0;){let d=n.slice(0,a);n=n.slice(a+1);let e;try{e=JSON.parse(d)}catch{continue}if(e.t==="hello"){t.role=e.role==="attach"?"attach":"daemon",t.info=e.role==="attach"?{pid:Number(e.pid)||null,tty:e.tty?String(e.tty).slice(0,80):null,term:e.term&&typeof e.term=="object"?e.term:null}:{},t.ready=!0;let r=k.join("");k=r?[r]:[];let s=x-M,p=typeof e.since=="number"&&e.since>=s&&e.since<=x?e.since:s;t.write(JSON.stringify({t:"hello",id:_,pid:f.pid||null,start:s,end:x,exited:m,code:O,signal:S,cols:w,rows:g,meta:L})+`
64
+ `);let u=r.slice(p-s);u&&t.write(JSON.stringify({t:"out",d:u,end:x})+`
65
+ `),m&&t.write(JSON.stringify({t:"exit",code:O,signal:S})+`
66
+ `),R({t:"clients",list:$()})}else if(e.t==="in"){if(!m)try{f.write(String(e.d||""))}catch{}}else if(e.t==="resize"){let r=Math.max(2,Math.min(1e3,Number(e.c)||w)),s=Math.max(1,Math.min(500,Number(e.r)||g));if(r!==w||s!==g){w=r,g=s;try{f.resize(w,g)}catch{}}}else if(e.t==="kill")if(J=!0,m)Y();else{try{f.kill(e.sig||"SIGHUP")}catch{}setTimeout(()=>{if(!m)try{f.kill("SIGKILL")}catch{}},3e3).unref()}else if(e.t==="meta"&&e.patch&&typeof e.patch=="object"){for(let[r,s]of Object.entries(e.patch))/^(title|kind|sessionId|run|startCwd|renamed)$/.test(r)&&(L[r]=s);D()}}});let o=()=>{T.delete(t)&&R({t:"clients",list:$()})};t.on("close",o),t.on("error",o)}),lt=process.umask(127);z.listen(v,()=>{process.umask(lt);try{y.chmodSync(v,384)}catch{}D(),E("listening ("+I.kind+", pid "+f.pid+")")});z.on("error",t=>{E("socket: "+t.message);try{f.kill("SIGHUP")}catch{}process.exit(5)});
@@ -0,0 +1,52 @@
1
+ # macOS permission prompts
2
+
3
+ ## What the prompt means
4
+
5
+ > **"node" would like to access data from other apps.**
6
+ > Keeping app data separate makes it easier to manage your privacy and security.
7
+
8
+ macOS 14 and later ask this when a program reads inside another app's data: `~/Library/Containers/<app>`, `~/Library/Group Containers/<group>`, and on macOS 15 and later an app's own folder in `~/Library/Application Support`. The privacy service is called App Data (`kTCCServiceSystemPolicyAppData`).
9
+
10
+ macOS asks on behalf of the *responsible* program. A program started by the login service is responsible for everything it starts, and so is a terminal app for its shells. Before Leftoff 1.0 the login service started `node` itself. So anything Leftoff ran showed up as "node": a headless Claude Code or Codex session, a board terminal, git. Claude Code's command sandbox asks for App Data on its own when it sets up.
11
+
12
+ Two things made it repeat:
13
+ - A bare `node` binary is remembered by its path, and a Homebrew or nvm upgrade puts node at a new path.
14
+ - macOS keeps no "Don't Allow" for this service, so after Don't Allow it simply asks again.
15
+
16
+ ## What Leftoff does about it (1.0)
17
+
18
+ - **Leftoff's own code stays out of other apps' data.** No walk, listing, git call or file read goes into `~/Library` except the two places Leftoff uses: its own `Application Support/Leftoff` and `LaunchAgents`. In particular:
19
+ - the board terminal's "Match my Ghostty" reads Ghostty's folder only once you pick that theme, not every time the menu opens;
20
+ - Syncthing's `config.xml` is read every 10 minutes instead of every minute;
21
+ - a git repo rooted at your home folder (dotfiles) never lets git walk `~/Library`.
22
+ - **The rest asks as "Leftoff", once.** On a Mac the login service now starts a small launcher inside `~/Library/Application Support/Leftoff/Agent/Leftoff.app` (bundle id `dev.leftoff.Leftoff`). That launcher runs node as its child, so macOS names Leftoff, and an Allow is kept for the app across Leftoff and node upgrades.
23
+ - The app is built on your Mac once, with the Command Line Tools' compiler, and signed ad hoc. It is never rebuilt by an upgrade.
24
+ - Without the Command Line Tools the service runs node directly, as before, and `leftoff doctor` says so (`xcode-select --install`, then `leftoff update`).
25
+
26
+ After `leftoff update`, expect at most one prompt that says "Leftoff". Click **Allow**.
27
+
28
+ ## If you clicked Don't Allow
29
+
30
+ macOS doesn't keep a Don't Allow for App Data, so the next request asks again; click Allow then.
31
+
32
+ To take back an Allow and be asked again (no sudo, only your own user's answers):
33
+
34
+ tccutil reset SystemPolicyAppData dev.leftoff.Leftoff
35
+
36
+ `tccutil reset SystemPolicyAppData` without the id resets the answer for every app.
37
+
38
+ ## See who is asking
39
+
40
+ leftoff doctor --tcc
41
+
42
+ This reads macOS's privacy log for the last 2 hours, then watches for a minute while you use Leftoff. It prints each request: who it was asked for (Leftoff, node, a terminal), which program touched the data, and whether a prompt showed. Options: `--seconds=120`, `--since=6h`, `--all` for every privacy service. It needs an admin account, never sudo; some fields may read `<private>`.
43
+
44
+ To watch it by hand:
45
+
46
+ log stream --style compact --predicate 'subsystem == "com.apple.TCC" AND eventMessage CONTAINS "AUTHREQ"'
47
+
48
+ `leftoff doctor` (without `--tcc`) says whether the login service runs as Leftoff.app.
49
+
50
+ ## Rebuilding
51
+
52
+ `leftoff update --force` rebuilds Leftoff.app. A new build is a new code signature, so macOS asks once more.
@@ -0,0 +1,43 @@
1
+ # Test plans from merged PRs
2
+
3
+ Merging 50 PRs shouldn't need 50 reviews to feel safe. A test plan turns what Leftoff already knows about a batch of merged work into one short manual script: what to click, what you should see, and why it's on the list, ordered by risk and fitted to the time you have.
4
+
5
+ ## Where to start one
6
+
7
+ - A project's page: **Test plan**, next to Decisions and Ask.
8
+ - A repo's or a project's decision log: **Test plan**.
9
+ - The PRs page: pick merged PRs, then **Test plan for these**.
10
+ - The terminal: `leftoff testplan --project acme` (or `--repo web`), with `--since 14d` and `--minutes 15`, `30` or `60`. It prints Markdown.
11
+
12
+ The window is the last 7 days unless you say otherwise. The budget is 30 minutes; 15 and 60 are the other choices, picked for each plan (it isn't a setting).
13
+
14
+ ## What it reads
15
+
16
+ Only Leftoff's own notes, plus what the PRs page already reads with `gh` where it's signed in:
17
+
18
+ - the PRs merged in the window that belong to the project or repo (or the ones you picked), each with its sessions;
19
+ - each PR's sessions' newest checkpoint (Goal, Progress, Decided);
20
+ - the walkthrough kept for its session: steps, Look / Assumption / Untested callouts, weak-test flags, spots, and the decisions a step follows or departs from. A PR with no kept walkthrough is not walked through on its own; it's read from its files, checkpoints and decisions and marked **no walkthrough**. **Walk through the N without one first** runs the usual walkthrough for each, one at a time;
21
+ - the decisions these PRs accepted, superseded or departed from.
22
+
23
+ ## What it makes
24
+
25
+ Up to about 12 checks that fit the budget. Each has a title, where to go (a screen, command or endpoint), at most 5 steps, what you should see, why it's on the list (with chips for the PRs, steps, checkpoints and decisions it comes from), minutes, and its risk as a shape and a word (▲ High, ◆ Medium, ▽ Low). Checks are ordered by risk, then by how many PRs each covers; checks on the same flow sit together.
26
+
27
+ These always become checks, or are listed under **Not covered** with the reason: every Look and Untested callout on a Core step, every weak test (the test isn't trusted, so the behaviour gets a manual check), every decision a PR departs from or that was superseded in the window ("D-b replaced D-a on Nov 2: confirm the new behaviour, and that the old one is gone"), and every migration, config or auth change. A last line says how many of the PRs have at least one check.
28
+
29
+ Every citation is checked against what Leftoff gave the model. A tag it made up is dropped, and a check left with none is dropped too, with a note under **What was checked in the answer**.
30
+
31
+ ## How it runs
32
+
33
+ The same way Ask does: Claude Code headless and read-only (Read, Grep and Glob, nothing that changes a file, the same budget and timeout), never a session on the board. Without Claude Code it uses your summary model with a smaller pack; with neither, it makes the plan from the rules above. Starting one from the board follows Ask's rule for a model run: this Mac's own browser, or a paired device with **Trust my paired devices** on.
34
+
35
+ ## While you test
36
+
37
+ Tick a check **Checked** or **Failed**; "3 of 9 checked" shows at the top. A failed check takes a one-line note, which goes into the export, and offers **Propose a decision**: what you learned goes into the repo's decision log as a proposed correction, linked to the plan, for you to accept or not later. It is never accepted on its own.
38
+
39
+ **Copy as Markdown** gives a checklist with checkboxes; **Download .html** gives one self-contained file in the walkthrough export's look.
40
+
41
+ ## Where it's kept
42
+
43
+ On the Mac that wrote it, in `~/.config/checkpoints/testplans/`, the newest 100 for 90 days, each deletable. Never in the synced folder. The list shows your other Macs' plans too, read over the peer link. Nothing is posted to GitHub or sent to anyone.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.1",
3
+ "version": "0.9.18-beta.10",
4
4
  "description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
5
5
  "author": {
6
6
  "name": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -506,6 +506,10 @@ export function renderCheckpointMd(cp) {
506
506
  // Informative / long checkpoints (v0.4.6): sections below the three lines.
507
507
  // Older readers only pick up the **Label:** lines, so they still work.
508
508
  const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
509
+ // 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
510
+ // sections, so an older reader (no section open yet) skips the heading and its lines.
511
+ const decided = cleanDecided(cp.decided)
512
+ if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
509
513
  for (const k of SECTION_ORDER) {
510
514
  const v = cp[k]
511
515
  if (k === 'recap') { if (v) lines.push('', '### ' + SECTION_TITLES[k], oneLine(v).replace(/^\*+/, '')); continue }
@@ -683,7 +687,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
683
687
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
684
688
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
685
689
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
686
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort']
690
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort', 'decisionsGiven']
687
691
 
688
692
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
689
693
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -1389,10 +1393,16 @@ const CHECKPOINT_BASE = [
1389
1393
  'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
1390
1394
  'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
1391
1395
  ]
1396
+ // 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
1397
+ const DECIDED_SECTION = [
1398
+ 'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
1399
+ '- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
1400
+ 'A task done, a file edited or a routine step is not a decision. Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
1401
+ ]
1392
1402
  const INFORMATIVE_SECTIONS = [
1393
1403
  'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
1394
- 'Decisions:',
1395
- '- <a decision made in the session and the reason, one line each, at most 4>',
1404
+ 'Approach notes:',
1405
+ '- <how the work is being done: the approach taken, a pattern or constraint it follows; one line each, at most 4. Not a choice between alternatives: those go only under Decided>',
1396
1406
  'Open questions:',
1397
1407
  '- <an unresolved question or uncertainty, at most 3>',
1398
1408
  'Files:',
@@ -1409,9 +1419,9 @@ const LONG_SECTIONS = [
1409
1419
  '3. <the step after that>',
1410
1420
  ]
1411
1421
  export const CHECKPOINT_SYSTEMS = {
1412
- short: [...CHECKPOINT_BASE, 'Write only those five lines.', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1413
- informative: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1414
- long: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1422
+ short: [...CHECKPOINT_BASE, ...DECIDED_SECTION, 'Write only those five lines (and Decided, when there is one).', 'Optionally a 6th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 90 words total.'].join('\n'),
1423
+ informative: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 280 words total.'].join('\n'),
1424
+ long: [...CHECKPOINT_BASE, ...DECIDED_SECTION, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 630 words total.'].join('\n'),
1415
1425
  }
1416
1426
  // The short prompt, as before v0.4.6 (kept under its old name for anything importing it).
1417
1427
  export const CHECKPOINT_SYSTEM = CHECKPOINT_SYSTEMS.short
@@ -1463,28 +1473,172 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
1463
1473
  head.push('Previous checkpoint: ' + prev, 'Files touched since then: ' + (files.length ? files.join(', ') : 'none'))
1464
1474
  if (Array.isArray(uncommitted)) head.push('Uncommitted files now: ' + (uncommitted.length ? uncommitted.slice(0, 12).join(', ') + (uncommitted.length > 12 ? ' and ' + (uncommitted.length - 12) + ' more' : '') : 'none (everything is committed)'))
1465
1475
  if (userNote) head.push('Note from the developer: ' + userNote)
1476
+ // 2026-11-03: already read from its questions and plans, no model needed; redacted like the prompt, since they're saved
1477
+ const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
1478
+ const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
1479
+ if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
1466
1480
  if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
1467
1481
  if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
1468
1482
  const slice = transcriptSlice(messages, { maxInputChars, cwd })
1469
1483
  const patterns = (cfg.redact && cfg.redact.patterns) || []
1470
1484
  const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
1471
- return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens }
1485
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
1486
+ }
1487
+
1488
+ // ---------------------------------------------------------------- decisions (2026-11-03)
1489
+ //
1490
+ // A decision is a choice between real alternatives that someone made or
1491
+ // approved in a session: a question you answered from its options, a plan you
1492
+ // approved, a correction you gave, or a trade-off the agent chose and said so.
1493
+ // Not a task done or a file edited. A checkpoint with none is normal.
1494
+ // In a checkpoint each is one line under "### Decided":
1495
+ // - <what was decided> · why: <one line> · over: <alt>; <alt> · from: asked
1496
+ // Both "·" and "|" separate the labelled parts (a model may write either).
1497
+ export const DECIDED_TITLE = 'Decided'
1498
+ export const DECIDED_MAX = 8
1499
+ export const DECISION_SOURCES = ['asked', 'plan', 'correction', 'agent']
1500
+ const DEC_PART = /\s+[·|]\s+(why|over|instead of|alternatives|from|source)\s*:\s*/i
1501
+
1502
+ export function parseDecidedLine(line) {
1503
+ let t = String(line == null ? '' : line).replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').replace(/\*\*/g, '').trim()
1504
+ if (!t || NONE_RE.test(t)) return null
1505
+ const d = { text: '', why: '', alternatives: [], source: 'agent' }
1506
+ const m0 = t.match(/^\[(asked|plan|correction|agent)\]\s*/i) // "[asked] …" also reads
1507
+ if (m0) { d.source = m0[1].toLowerCase(); t = t.slice(m0[0].length) }
1508
+ const parts = t.split(DEC_PART)
1509
+ d.text = parts[0].trim()
1510
+ for (let i = 1; i + 1 < parts.length; i += 2) {
1511
+ const k = parts[i].toLowerCase(), v = parts[i + 1].trim()
1512
+ if (k === 'why') d.why = v
1513
+ else if (k === 'from' || k === 'source') { const src = v.toLowerCase().replace(/[^a-z]/g, ''); if (DECISION_SOURCES.includes(src)) d.source = src }
1514
+ else d.alternatives = v.split(/\s*;\s*/).map((x) => x.trim()).filter((x) => x && !NONE_RE.test(x))
1515
+ }
1516
+ return cleanDecided([d])[0] || null
1517
+ }
1518
+
1519
+ export function renderDecidedLine(d) {
1520
+ const safe = (x) => oneLine(x).replace(/\s+[·|]\s+/g, ' - ')
1521
+ return '- ' + safe(d.text) + (d.why ? ' · why: ' + safe(d.why) : '') + (d.alternatives && d.alternatives.length ? ' · over: ' + d.alternatives.map((a) => safe(a).replace(/;/g, ',')).join('; ') : '') + ' · from: ' + (d.source || 'agent')
1522
+ }
1523
+
1524
+ // Any list -> [{ text, why, alternatives, source }], one line each, deduplicated by text.
1525
+ export function cleanDecided(list) {
1526
+ const out = [], seen = new Set()
1527
+ for (const x of Array.isArray(list) ? list : []) {
1528
+ if (!x || typeof x !== 'object') continue
1529
+ const text = clip(oneLine(x.text).replace(/^["“]|["”]$/g, ''), 240)
1530
+ if (!text || NONE_RE.test(text)) continue
1531
+ const k = decisionNorm(text)
1532
+ if (seen.has(k)) continue
1533
+ seen.add(k)
1534
+ const alternatives = (Array.isArray(x.alternatives) ? x.alternatives : []).map((a) => clip(oneLine(a), 120)).filter(Boolean).slice(0, 6)
1535
+ out.push({ text, why: clip(oneLine(x.why), 240), alternatives, source: DECISION_SOURCES.includes(x.source) ? x.source : 'agent' })
1536
+ if (out.length >= DECIDED_MAX) break
1537
+ }
1538
+ return out
1539
+ }
1540
+ export const decisionNorm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
1541
+
1542
+ // A decision's key in a repo's log: the same decision from the same session is
1543
+ // the same entry wherever it was read (the mod or the daemon). 12 hex characters.
1544
+ export function decisionKey(sessionId, text) {
1545
+ const str = String(sessionId || '') + '\n' + decisionNorm(text)
1546
+ let h1 = 0x811c9dc5, h2 = 0x01000193 ^ 0x5bd1e995
1547
+ for (let i = 0; i < str.length; i++) {
1548
+ const c = str.charCodeAt(i)
1549
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
1550
+ h2 = Math.imul(h2 ^ c, 0x5bd1e995) >>> 0
1551
+ h2 = (h2 ^ (h2 >>> 15)) >>> 0
1552
+ }
1553
+ return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 12)
1554
+ }
1555
+
1556
+ // "User has answered your questions: "Q"="A", "Q2"="B". You can now continue…" -> { Q: A }
1557
+ function questionAnswers(text, questions) {
1558
+ const out = {}
1559
+ const t = String(text || '')
1560
+ for (let i = 0; i < questions.length; i++) {
1561
+ const q = questions[i]
1562
+ const at = t.indexOf('"' + q + '"="')
1563
+ if (at < 0) continue
1564
+ const from = at + q.length + 4
1565
+ let end = -1
1566
+ for (let j = i + 1; j < questions.length && end < 0; j++) { const n = t.indexOf('", "' + questions[j] + '"="', from); if (n >= 0) end = n }
1567
+ if (end < 0) { const m = t.slice(from).search(/"(?:\.\s|\.?$|,\s*"[^"]*"=")/); end = m >= 0 ? from + m : t.length }
1568
+ out[q] = t.slice(from, end).trim()
1569
+ }
1570
+ return out
1571
+ }
1572
+
1573
+ const PLAN_OK = /(?:approved your plan|user has approved|plan (?:was )?approved)/i
1574
+ function planHeadline(plan) {
1575
+ const lines = String(plan || '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean)
1576
+ const h = lines.find((l) => /^#{1,3}\s+\S/.test(l)) || lines[0] || ''
1577
+ return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
1578
+ }
1579
+
1580
+ // The decisions a transcript states outright, with no model: a question you
1581
+ // answered from its options (AskUserQuestion, the board's Needs you), its other
1582
+ // options as the alternatives; a plan you approved (ExitPlanMode). Messages are
1583
+ // src/transcript.js's shape, which is also $.session.messages()'s.
1584
+ export function decisionsFromMessages(messages) {
1585
+ const out = []
1586
+ for (const m of messages || []) {
1587
+ if (!m || m.role !== 'assistant') continue
1588
+ for (const u of m.toolUses || []) {
1589
+ const name = u && (u.tool || u.name)
1590
+ if (!u || u.isError || !u.input) continue
1591
+ if (name === 'AskUserQuestion' && Array.isArray(u.input.questions) && u.text) {
1592
+ const qs = u.input.questions.filter((q) => q && q.question && Array.isArray(q.options) && q.options.length >= 2)
1593
+ const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
1594
+ for (const q of qs) {
1595
+ const a = ans[q.question]
1596
+ if (!a) continue
1597
+ const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
1598
+ const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
1599
+ const chosen = picked.length ? picked.join(', ') : a
1600
+ const opt = q.options.find((o) => o && o.label === picked[0])
1601
+ out.push({
1602
+ text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
1603
+ why: opt && opt.description ? opt.description : '',
1604
+ alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
1605
+ source: 'asked',
1606
+ })
1607
+ }
1608
+ }
1609
+ if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
1610
+ const head = planHeadline(u.input.plan)
1611
+ if (head) out.push({ text: clip('Plan approved: ' + head, 240), why: '', alternatives: [], source: 'plan' })
1612
+ }
1613
+ }
1614
+ }
1615
+ return cleanDecided(out)
1616
+ }
1617
+
1618
+ // The checkpoint's decisions: what the transcript states outright first, then the model's.
1619
+ // earlier: decisions already in this session's previous checkpoints (by text), left out.
1620
+ export function mergeDecided(found, fromModel, { earlier = [] } = {}) {
1621
+ const seen = new Set((earlier || []).map((d) => decisionNorm(d && d.text)))
1622
+ return cleanDecided([...(found || []), ...(fromModel || [])]).filter((d) => !seen.has(decisionNorm(d.text)))
1472
1623
  }
1473
1624
 
1474
1625
  // The extra sections of an informative / long checkpoint, by the heading a
1475
1626
  // model (or a checkpoint file) uses for them.
1476
1627
  const SECTION_KEYS = {
1477
- 'decisions': 'decisions', 'key decisions': 'decisions',
1628
+ // 1i (testplan): the section is "Approach notes" now (real choices go only under Decided); a checkpoint
1629
+ // written before, or a model still saying "Decisions:" / "Key decisions", reads into the same field.
1630
+ 'approach notes': 'decisions', 'approach': 'decisions', 'decisions': 'decisions', 'key decisions': 'decisions',
1478
1631
  'open questions': 'questions', 'questions': 'questions',
1479
1632
  'files': 'files', 'files touched': 'files',
1480
1633
  'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
1481
1634
  'recap': 'recap',
1482
1635
  'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
1636
+ 'decided': 'decided', // 2026-11-03 decision memory
1483
1637
  }
1484
- export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1638
+ export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
1485
1639
  const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
1486
1640
  const NONE_RE = /^(none|n\/a|nothing|-|—|none yet|no(ne)? (open )?questions?)\.?$/i
1487
- const LIMITS = { decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1641
+ const LIMITS = { decided: DECIDED_MAX, decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
1488
1642
 
1489
1643
  // "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
1490
1644
  export function parseFileNote(text) {
@@ -1495,7 +1649,7 @@ export function parseFileNote(text) {
1495
1649
  return p ? { path: p[1], why: '' } : { path: '', why: t }
1496
1650
  }
1497
1651
 
1498
- function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1652
+ function emptyExtras() { return { decided: [], decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
1499
1653
  export function hasExtras(cp) {
1500
1654
  return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
1501
1655
  }
@@ -1510,6 +1664,7 @@ function parseSections(lines) {
1510
1664
  if (!v || NONE_RE.test(v)) return
1511
1665
  if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
1512
1666
  if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
1667
+ if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
1513
1668
  ex[k].push(v)
1514
1669
  }
1515
1670
  for (const raw of lines) {
@@ -1528,8 +1683,11 @@ function parseSections(lines) {
1528
1683
  if (item) push(cur, item[1])
1529
1684
  else if (cur === 'recap') push(cur, line)
1530
1685
  else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
1686
+ else if (cur === 'decided') continue // a decision is one bullet line; loose prose under the heading is not one
1531
1687
  else push(cur, line)
1532
1688
  }
1689
+ // 1i: a line a model put under both Decided and the approach notes is a decision: it stays under Decided only
1690
+ if (ex.decided.length && ex.decisions.length) { const dn = new Set(ex.decided.map((d) => decisionNorm(d.text))); ex.decisions = ex.decisions.filter((t) => !dn.has(decisionNorm(String(t).split(/\s+·\s+|\s+[-—–]\s+because\b/)[0]))) }
1533
1691
  for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
1534
1692
  ex.recap = clip(ex.recap, 1600)
1535
1693
  return ex
@@ -1707,6 +1865,8 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1707
1865
  else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
1708
1866
  else if (lastEdit >= 0 && lastCommit < lastEdit) next = 'Commit, or keep going.'
1709
1867
  const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
1868
+ const found = decisionsFromMessages(ms_) // 2026-11-03: only what the transcript states outright; the heuristic guesses none
1869
+ if (found.length) base.decided = found
1710
1870
  const len = normalizeLength(length)
1711
1871
  if (len === 'short') return base
1712
1872
  const ex = emptyExtras()
@@ -1733,7 +1893,7 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
1733
1893
  ].filter(Boolean).join(' ')
1734
1894
  ex.nextSteps = next ? [next] : []
1735
1895
  }
1736
- return { ...base, ...ex }
1896
+ return { ...base, ...ex, decided: base.decided || [] }
1737
1897
  }
1738
1898
 
1739
1899
  // Fallback title: {repo}@{branch} · {first 6 words of the first prompt}