@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11
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/CHANGELOG.md +46 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +787 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/SECURITY.md +1 -1
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +211 -13
- package/mod/hooks/register.js +196 -7
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +279 -4
- package/ui/app.js +661 -53
- package/ui/ask.js +5 -3
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +245 -0
- package/ui/fs-side.js +29 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +36 -5
- package/ui/prs.js +15 -5
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/summary-examples.js +2 -2
- package/ui/tabs.js +10 -3
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +73 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
|
@@ -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)});
|
package/docs/SECURITY.md
CHANGED
|
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
|
|
|
94
94
|
|
|
95
95
|
When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
|
|
96
96
|
|
|
97
|
-
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
97
|
+
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
98
98
|
|
|
99
99
|
### Live link (0.9.8 beta)
|
|
100
100
|
|
|
@@ -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.
|
|
3
|
+
"version": "0.9.18-beta.11",
|
|
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, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). 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
|
-
'
|
|
1395
|
-
'- <
|
|
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,210 @@ 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
|
+
// 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
|
|
1581
|
+
// it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
|
|
1582
|
+
const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
|
|
1583
|
+
const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
|
|
1584
|
+
const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
|
|
1585
|
+
export function isOneOffQuestion(q) {
|
|
1586
|
+
const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
|
|
1587
|
+
if (labels.length < 2) return false
|
|
1588
|
+
if (labels.every((l) => ACTOR_OPT.test(l))) return true
|
|
1589
|
+
return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// The transcript's own decisions, oldest first, and the answers it later changed:
|
|
1593
|
+
// a question asked again (same header, else the same question) and answered
|
|
1594
|
+
// differently keeps only the last answer; the earlier ones are `reversed` (texts).
|
|
1595
|
+
function readStated(messages) {
|
|
1596
|
+
const out = [], at = new Map(), replaced = new Set()
|
|
1597
|
+
for (const m of messages || []) {
|
|
1598
|
+
if (!m || m.role !== 'assistant') continue
|
|
1599
|
+
for (const u of m.toolUses || []) {
|
|
1600
|
+
const name = u && (u.tool || u.name)
|
|
1601
|
+
if (!u || u.isError || !u.input) continue
|
|
1602
|
+
if (name === 'AskUserQuestion' && Array.isArray(u.input.questions) && u.text) {
|
|
1603
|
+
const qs = u.input.questions.filter((q) => q && q.question && Array.isArray(q.options) && q.options.length >= 2)
|
|
1604
|
+
const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
|
|
1605
|
+
for (const q of qs) {
|
|
1606
|
+
const a = ans[q.question]
|
|
1607
|
+
if (!a || isOneOffQuestion(q)) continue
|
|
1608
|
+
const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
|
|
1609
|
+
const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
|
|
1610
|
+
const chosen = picked.length ? picked.join(', ') : a
|
|
1611
|
+
const opt = q.options.find((o) => o && o.label === picked[0])
|
|
1612
|
+
const d = {
|
|
1613
|
+
text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
|
|
1614
|
+
why: opt && opt.description ? opt.description : '',
|
|
1615
|
+
alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
|
|
1616
|
+
source: 'asked',
|
|
1617
|
+
}
|
|
1618
|
+
const topic = 'q:' + decisionNorm(q.header || q.question)
|
|
1619
|
+
const was = at.get(topic)
|
|
1620
|
+
if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
|
|
1621
|
+
if (was != null && out[was]) continue // the same answer again
|
|
1622
|
+
at.set(topic, out.length); out.push(d)
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
|
|
1626
|
+
const head = planHeadline(u.input.plan)
|
|
1627
|
+
if (head) out.push({ text: clip('Plan approved: ' + head, 240), why: '', alternatives: [], source: 'plan' })
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const found = out.filter(Boolean)
|
|
1632
|
+
const kept = new Set(found.map((d) => decisionNorm(d.text)))
|
|
1633
|
+
return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// The decisions a transcript states outright, with no model: a question you
|
|
1637
|
+
// answered from its options (AskUserQuestion, the board's Needs you), its other
|
|
1638
|
+
// options as the alternatives, only its last answer; a plan you approved
|
|
1639
|
+
// (ExitPlanMode). Messages are src/transcript.js's shape, which is also
|
|
1640
|
+
// $.session.messages()'s.
|
|
1641
|
+
export function decisionsFromMessages(messages) {
|
|
1642
|
+
return cleanDecided(readStated(messages).found)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
|
|
1646
|
+
// redacted as checkpoints save them, and the texts of answers changed later in the session.
|
|
1647
|
+
export function statedDecisions(messages, patterns = []) {
|
|
1648
|
+
const red = (t) => redact(String(t || ''), patterns || [])
|
|
1649
|
+
const { found, reversed } = readStated(messages)
|
|
1650
|
+
return {
|
|
1651
|
+
found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
|
|
1652
|
+
reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// The checkpoint's decisions: what the transcript states outright first, then the model's.
|
|
1657
|
+
// earlier: decisions already in this session's previous checkpoints (by text), left out.
|
|
1658
|
+
export function mergeDecided(found, fromModel, { earlier = [] } = {}) {
|
|
1659
|
+
const seen = new Set((earlier || []).map((d) => decisionNorm(d && d.text)))
|
|
1660
|
+
return cleanDecided([...(found || []), ...(fromModel || [])]).filter((d) => !seen.has(decisionNorm(d.text)))
|
|
1472
1661
|
}
|
|
1473
1662
|
|
|
1474
1663
|
// The extra sections of an informative / long checkpoint, by the heading a
|
|
1475
1664
|
// model (or a checkpoint file) uses for them.
|
|
1476
1665
|
const SECTION_KEYS = {
|
|
1477
|
-
|
|
1666
|
+
// 1i (testplan): the section is "Approach notes" now (real choices go only under Decided); a checkpoint
|
|
1667
|
+
// written before, or a model still saying "Decisions:" / "Key decisions", reads into the same field.
|
|
1668
|
+
'approach notes': 'decisions', 'approach': 'decisions', 'decisions': 'decisions', 'key decisions': 'decisions',
|
|
1478
1669
|
'open questions': 'questions', 'questions': 'questions',
|
|
1479
1670
|
'files': 'files', 'files touched': 'files',
|
|
1480
1671
|
'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
|
|
1481
1672
|
'recap': 'recap',
|
|
1482
1673
|
'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
|
|
1674
|
+
'decided': 'decided', // 2026-11-03 decision memory
|
|
1483
1675
|
}
|
|
1484
|
-
export const SECTION_TITLES = { decisions: '
|
|
1676
|
+
export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
|
|
1485
1677
|
const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
|
|
1486
1678
|
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 }
|
|
1679
|
+
const LIMITS = { decided: DECIDED_MAX, decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
|
|
1488
1680
|
|
|
1489
1681
|
// "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
|
|
1490
1682
|
export function parseFileNote(text) {
|
|
@@ -1495,7 +1687,7 @@ export function parseFileNote(text) {
|
|
|
1495
1687
|
return p ? { path: p[1], why: '' } : { path: '', why: t }
|
|
1496
1688
|
}
|
|
1497
1689
|
|
|
1498
|
-
function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
|
|
1690
|
+
function emptyExtras() { return { decided: [], decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
|
|
1499
1691
|
export function hasExtras(cp) {
|
|
1500
1692
|
return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
|
|
1501
1693
|
}
|
|
@@ -1510,6 +1702,7 @@ function parseSections(lines) {
|
|
|
1510
1702
|
if (!v || NONE_RE.test(v)) return
|
|
1511
1703
|
if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
|
|
1512
1704
|
if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
|
|
1705
|
+
if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
|
|
1513
1706
|
ex[k].push(v)
|
|
1514
1707
|
}
|
|
1515
1708
|
for (const raw of lines) {
|
|
@@ -1528,8 +1721,11 @@ function parseSections(lines) {
|
|
|
1528
1721
|
if (item) push(cur, item[1])
|
|
1529
1722
|
else if (cur === 'recap') push(cur, line)
|
|
1530
1723
|
else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
|
|
1724
|
+
else if (cur === 'decided') continue // a decision is one bullet line; loose prose under the heading is not one
|
|
1531
1725
|
else push(cur, line)
|
|
1532
1726
|
}
|
|
1727
|
+
// 1i: a line a model put under both Decided and the approach notes is a decision: it stays under Decided only
|
|
1728
|
+
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
1729
|
for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
|
|
1534
1730
|
ex.recap = clip(ex.recap, 1600)
|
|
1535
1731
|
return ex
|
|
@@ -1707,6 +1903,8 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
|
|
|
1707
1903
|
else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
|
|
1708
1904
|
else if (lastEdit >= 0 && lastCommit < lastEdit) next = 'Commit, or keep going.'
|
|
1709
1905
|
const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
|
|
1906
|
+
const found = decisionsFromMessages(ms_) // 2026-11-03: only what the transcript states outright; the heuristic guesses none
|
|
1907
|
+
if (found.length) base.decided = found
|
|
1710
1908
|
const len = normalizeLength(length)
|
|
1711
1909
|
if (len === 'short') return base
|
|
1712
1910
|
const ex = emptyExtras()
|
|
@@ -1733,7 +1931,7 @@ export function heuristicCheckpoint(messages, session = {}, { length = 'short',
|
|
|
1733
1931
|
].filter(Boolean).join(' ')
|
|
1734
1932
|
ex.nextSteps = next ? [next] : []
|
|
1735
1933
|
}
|
|
1736
|
-
return { ...base, ...ex }
|
|
1934
|
+
return { ...base, ...ex, decided: base.decided || [] }
|
|
1737
1935
|
}
|
|
1738
1936
|
|
|
1739
1937
|
// Fallback title: {repo}@{branch} · {first 6 words of the first prompt}
|
|
@@ -1750,7 +1948,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
|
|
|
1750
1948
|
export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
|
|
1751
1949
|
|
|
1752
1950
|
export function cleanTitle(text) {
|
|
1753
|
-
const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
1951
|
+
const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
|
|
1754
1952
|
return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
|
|
1755
1953
|
}
|
|
1756
1954
|
|