@leftoff/cli 0.9.18-beta.4 → 0.9.18-beta.6

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/ui/app.css CHANGED
@@ -5396,3 +5396,99 @@ code .file-ref-off { font-size: inherit; }
5396
5396
  .sub .s-hooks-ring { width: 8px; height: 8px; border-radius: 50%; border: 1.5px dashed currentColor; box-sizing: border-box; flex: none; }
5397
5397
  .fs-head .sub > .s-hooks { height: 22px; vertical-align: middle; }
5398
5398
  .fs-side.is-scrolled .fs-head .sub > .s-hooks { display: none; }
5399
+ /* 2026-11-03 which hooks were skipped and why: the same note, its words cut short on a narrow header (the whole in its tip) */
5400
+ .sub .s-hooks-skip { max-width: min(420px, 100%); min-width: 0; }
5401
+ .sub .s-hooks-skip .s-hooks-t { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
5402
+
5403
+ /* 2026-11-03 "Let me answer prompts here" (ui/hook-fix.js): the hooks-skipped note's way out. Centered; a bottom sheet on a phone. */
5404
+ .sub .s-hooks-fix { margin-left: 6px; min-height: 44px; padding: 0 10px; border: 1px solid var(--btn-line); border-radius: 8px; background: transparent; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
5405
+ @media (pointer: fine) { .sub .s-hooks-fix { min-height: 32px; padding: 0 9px; } }
5406
+ .sub .s-hooks-fix:hover { background: var(--btn-hover); }
5407
+ .sub .s-hooks-fix:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5408
+ .fs-side.is-scrolled .fs-head .sub > .s-hooks-fix { display: none; }
5409
+ .sub .s-hooks-applied .s-hooks-ring { border-style: dotted; }
5410
+ .hf-back { position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 16px; }
5411
+ .hf-modal { width: min(560px, 100%); max-height: calc(100dvh - 32px); overflow: auto; background: var(--card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--toast-shadow); padding: 16px 18px 14px; color: var(--text2); }
5412
+ .hf-grab { display: none; }
5413
+ .hf-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
5414
+ .hf-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text); text-transform: none; letter-spacing: normal; }
5415
+ .hf-p { margin: 8px 0; font-size: 13px; line-height: 1.5; }
5416
+ .hf-quiet { color: var(--muted); font-size: 12.5px; }
5417
+ .hf-file code, .hf-p code { font-size: 12px; overflow-wrap: anywhere; }
5418
+ .hf-file { margin: 4px 0; }
5419
+ .hf-code { margin: 6px 0; padding: 10px 12px; background: var(--field); border: 1px solid var(--line); border-radius: 8px; font-size: 12px; line-height: 1.45; overflow: auto; white-space: pre; color: var(--text); user-select: all; }
5420
+ .hf-mark { display: inline-block; width: 9px; height: 9px; margin-right: 7px; vertical-align: 0; box-sizing: border-box; }
5421
+ .hf-mark-ok { border-radius: 50%; background: var(--green); }
5422
+ .hf-mark-admin { border-radius: 2px; border: 1.5px solid var(--yellow); transform: rotate(45deg); }
5423
+ .hf-mark-off { border-radius: 50%; border: 1.5px dashed var(--muted); }
5424
+ .hf-ign { margin-top: 10px; padding-top: 6px; border-top: 1px solid var(--line); }
5425
+ .hf-acts { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
5426
+ .hf-acts .btn, .hf-x { min-height: 44px; min-width: 44px; justify-content: center; }
5427
+ @media (pointer: fine) { .hf-acts .btn, .hf-x { min-height: 32px; min-width: 32px; } }
5428
+ .hf-modal .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5429
+ @media (max-width: 640px) {
5430
+ .hf-back { align-items: flex-end; padding: 0; }
5431
+ .hf-modal { width: 100%; max-height: 88dvh; border-radius: var(--radius) var(--radius) 0 0; border-bottom: none; padding: 8px 16px calc(14px + env(safe-area-inset-bottom)); }
5432
+ .hf-grab { display: block; width: 36px; height: 4px; border-radius: 2px; background: var(--line-strong); margin: 2px auto 8px; }
5433
+ .hf-acts { justify-content: stretch; }
5434
+ .hf-acts .btn { flex: 1 1 auto; }
5435
+ }
5436
+
5437
+ /* 2026-11-03 decision memory */
5438
+ .dec-list { list-style: none; margin: 0; padding: 0 0 6px; display: grid; gap: 10px; }
5439
+ .dec-row { font-size: 13.5px; line-height: 1.45; color: var(--text); }
5440
+ .dec-id { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); }
5441
+ .dec-why, .dec-alt, .dec-src { font-size: 12.5px; color: var(--text2); margin-top: 2px; }
5442
+ .dec-src { color: var(--muted, var(--text2)); }
5443
+ .dec-st { margin-left: 6px; font-size: 12px; color: var(--text2); white-space: nowrap; }
5444
+ .dec-open { display: inline-flex; align-items: center; min-height: 44px; font-size: 13px; }
5445
+
5446
+ .dec-page .dec-head h1 { margin: 4px 0 2px; }
5447
+ .dec-tl { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 0; }
5448
+ .dec-item { position: relative; padding: 12px 0 12px 18px; border-left: 2px solid var(--line); margin-left: 6px; }
5449
+ .dec-item::before { content: ''; position: absolute; left: -6px; top: 17px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 2px solid var(--text2); box-sizing: border-box; }
5450
+ .dec-item.dec-accepted::before { background: var(--text2); }
5451
+ .dec-item.dec-superseded, .dec-item.dec-rejected { opacity: .78; }
5452
+ .dec-item.dec-superseded::before { border-color: var(--line-strong, var(--line)); }
5453
+ .dec-item.dec-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; }
5454
+ .dec-h { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 12.5px; color: var(--text2); }
5455
+ .dec-date { font-variant-numeric: tabular-nums; }
5456
+ .dec-repo { padding: 1px 6px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; }
5457
+ .dec-item .dec-st { margin-left: 0; }
5458
+ .dec-item .dec-text { font-size: 15px; line-height: 1.45; margin-top: 4px; color: var(--text); overflow-wrap: anywhere; }
5459
+ .dec-meta { font-size: 12.5px; color: var(--text2); margin-top: 4px; }
5460
+ .dec-chains { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 12.5px; color: var(--text2); }
5461
+ .dec-chip { font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); background: transparent; border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; min-height: 24px; cursor: pointer; position: relative; }
5462
+ .dec-chip::after { content: ''; position: absolute; inset: -10px -4px; }
5463
+ .dec-chip:hover { border-color: var(--text2); }
5464
+ .dec-sug { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed var(--line); border-radius: 8px; font-size: 13px; }
5465
+ .dec-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
5466
+ .dec-acts .btn, .dec-acts .linkish { min-height: 44px; }
5467
+ .dec-acts-quiet { margin-top: 0; }
5468
+ .dec-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 4px; }
5469
+ .dec-ctl .sask-opt, .dec-ctl .btn, .dec-store-opts .sask-opt { min-height: 44px; }
5470
+ .dec-store-h { font-weight: 600; margin-bottom: 6px; }
5471
+ .dec-store-opts { display: inline-flex; flex-wrap: wrap; gap: 6px; }
5472
+ .dec-store p { margin: 8px 0 0; max-width: 62ch; }
5473
+ .dec-edit { display: grid; gap: 8px; margin-top: 8px; max-width: 640px; }
5474
+ .dec-edit label { display: grid; gap: 4px; font-size: 12.5px; color: var(--text2); }
5475
+ .dec-in { font: inherit; font-size: 15px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); min-height: 44px; box-sizing: border-box; width: 100%; }
5476
+ @media (pointer: coarse) { .dec-in { font-size: 16px; } }
5477
+ .dec-in:focus-visible { outline: 2px solid var(--accent, var(--text2)); outline-offset: 1px; }
5478
+ .dec-flash { background: color-mix(in srgb, var(--text2) 10%, transparent); transition: background .6s; }
5479
+ @media (prefers-reduced-motion: reduce) { .dec-flash { transition: none; } }
5480
+ .dec-proj p { margin: 4px 0 10px; }
5481
+
5482
+ .wt-decs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
5483
+ .wt-dec { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 4px 8px; font-weight: 400; }
5484
+ @media (pointer: coarse) { .wt-dec { min-height: 44px; } }
5485
+ .wt-dec .wt-dec-id { font-weight: 600; }
5486
+ .wt-dec .wt-dec-t { font: 12.5px/1.3 var(--sans, system-ui, sans-serif); color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42ch; }
5487
+ .wt-dec-gone { border-style: dashed; }
5488
+ .wt-dec-w { font: 12px/1 var(--sans, system-ui, sans-serif); color: var(--text2); }
5489
+ .dec-chainview { margin-top: 4px; }
5490
+
5491
+ .dv-isdec .dv-dl { display: none !important; }
5492
+ .dec-ctl .sask-opt { border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: transparent; color: var(--text2); cursor: pointer; }
5493
+ .dec-ctl .sask-opt[aria-pressed="true"] { color: var(--text); border-color: var(--line-strong, var(--text2)); }
5494
+ @media (min-width: 700px) { .dv-isdec .dv-sheet { max-width: 760px; height: auto; max-height: 80vh; margin-left: auto; margin-right: auto; } }
package/ui/app.js CHANGED
@@ -39,6 +39,7 @@ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box k
39
39
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
40
40
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
41
41
  import { mdInline, renderSection } from './changelog-md.js'
42
+ import { hooksFixButtonHtml, openHookFix } from './hook-fix.js' // 2026-11-03 "Let me answer prompts here"
42
43
  import { crumbHtml, metaLineHtml, detailsHtml, detailsOpen, rememberDetails, branchTileHtml } from './session-head.js' // 0.9.11: the session page's smaller head
43
44
  import { projectHref, threadHref, projectPageHtml, askPageHtml, threadHtml as askThreadHtml, recentAsksHtml, fillAsk, pickupState, paletteProjectItems, scopeOfSession, askHref, sessionAskHtml, sessionAskScope, sessionAskToken, scopeHref, pickListHtml, pickFull, PICK_MAX, ARCHIVE_UNDO_MS } from './ask.js' // Ask about a project (roadmap #4)
44
45
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash, prCopyClick } from './prs.js'
@@ -73,6 +74,7 @@ import { loginBannersHtml, loginSig, dismissedKeys as loginDismissed, dismissLog
73
74
  import { isDone, stillWorking, canMarkDone, DONE_CONFIRM, DONE_TIP, UNDONE_TIP, doneChipHtml } from './done.js' // 0.9.15 Done
74
75
  import { mountTerminal, escInTerminal } from './term.js' // 0.9.15 the embedded terminal
75
76
  import { termTipFor } from './term-host.js' // 0.9.15 a terminal for another Mac's session
77
+ import { decidedListHtml, decisionScope, decisionsPageHtml, projectDecisionsHtml, decisionItemHtml, decisionsHref } from './decisions.js' // 2026-11-03 decision memory
76
78
  import { recapLineHtml, beforeClearHtml } from './recap-line.js' // 0.9.15 Claude Code's recap; before /clear
77
79
  import { Presence, cardRows, awayWords } from './away.js' // 0.9.15 a card shows more the longer you've been away
78
80
  import { backLine, backLineHtml, BACK_PREFIX, BACK_AWAY_MS } from './back-line.js' // 0.9.15 the recap above the Live composer
@@ -471,7 +473,23 @@ const statusPill = (s) => { if (ownerOffline(s)) return offlinePill(s); const st
471
473
  // 2026-11-03 Claude Code's own hook events never came for this session (only the mod's), so its turns
472
474
  // are read from the transcript: a quiet note, a dashed ring plus words, the why in the tip
473
475
  const HOOKS_QUIET_TIP = 'Claude Code’s hooks aren’t reaching Leftoff for this session, so its prompts and turn ends are read from the transcript instead (a few seconds late). Restarting Claude Code in this folder, or leftoff doctor, usually brings them back.'
474
- const hooksQuietHtml = (s) => (s && s.hooksQuiet && !s.endedAt ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : '')
476
+ // 2026-11-03 and when Leftoff knows which (Claude Code passed them over: a plugin seated ahead of Leftoff's
477
+ // sends them on past it), the note says which and why; the daemon writes the words (src/hook-skips.js)
478
+ const hooksQuietHtml = (s) => {
479
+ if (!s || s.endedAt) return ''
480
+ const k = s.hooksSkipped
481
+ if (k && k.label) return '<span class="s-hooks s-hooks-skip' + (k.applied ? ' s-hooks-applied' : '') + '" data-tip="' + esc(k.tip || k.label) + '"><span class="s-hooks-ring" aria-hidden="true"></span><span class="s-hooks-t">' + esc(k.label) + '</span></span>' + hooksFixButtonHtml(s, { esc }) // 2026-11-03 applied: "restart Claude Code to apply", no button
482
+ return s.hooksQuiet ? '<span class="s-hooks" data-tip="' + esc(HOOKS_QUIET_TIP) + '"><span class="s-hooks-ring" aria-hidden="true"></span>hooks not reporting</span>' : ''
483
+ }
484
+ // the note's button: the sheet with the managed-settings lines (ui/hook-fix.js)
485
+ document.addEventListener('click', (e) => {
486
+ const b = e.target.closest && e.target.closest('[data-hookfix]')
487
+ if (!b) return
488
+ e.preventDefault()
489
+ const sid = b.dataset.hookfix
490
+ const s = (state.session && state.session.id === sid ? state.session : null) || (state.sessions || []).find((x) => x.id === sid) || { id: sid }
491
+ openHookFix({ session: s, from: b, api, esc, toast, thisMachine: state.health && state.health.machine })
492
+ })
475
493
  const offlinePill = (s) => { const w = offlineLine(s); return '<span class="pill s-offline" data-tip="' + esc(offlineTip(s, w, { was: offlineWas(s) })) + '">' + esc(w) + '</span>' }
476
494
 
477
495
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -2923,6 +2941,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2923
2941
  // 2026-10-29 a calmer panel: only the newest checkpoint (Stopped + Next, the rest behind More); older ones fold
2924
2942
  side += '<section class="fs-sec fs-cps" id="fsCps"><h2>Checkpoints <span class="count">' + cps.length + '</span></h2>'
2925
2943
  side += !cps.length && !wrRow ? '<div class="empty">No checkpoints yet.</div>' : '<div class="timeline">' + wrRow + (cps.length ? cpLatestHtml(cps[0], s.agent ? null : s.id, paths) : '') + '</div>'
2944
+ if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) noteSessionDecisions(s)
2945
+ if (cps.length && Array.isArray(cps[0].decided) && cps[0].decided.length) side += foldHtml({ key: 'cp-decided', title: 'Decided', count: cps[0].decided.length, open: sideFolds.open('cp-decided'), cls: 'fs-fold-in', body: decidedListHtml(cps[0].decided, { esc, log: decisionsUi.bySession[s.id] || null, repoId: s.decisionRepo || null }) }, { esc }) // 2026-11-03 decision memory
2926
2946
  if (cps.length > 1) side += foldHtml({ key: 'cps-earlier', title: 'Earlier checkpoints', count: cps.length - 1, open: sideFolds.open('cps-earlier'), cls: 'fs-fold-in', body: '<div class="timeline">' + cpTimeline(cps.slice(1), null, null, paths) + '</div>' }, { esc })
2927
2947
  const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty') // 0.9.0: written with another session's branch: kept, folded away
2928
2948
  if (doubted.length) side += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
@@ -4426,6 +4446,131 @@ function cpHeadLine(cp) {
4426
4446
  return '<div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
4427
4447
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cpByText(cp.generator)) + cpByHintHtml(cp.generator) + '</span>' : '') + '</div>'
4428
4448
  }
4449
+ // 2026-11-03 decision memory: the log's word on a session's decisions (by text), once its repo's log is loaded
4450
+ const decisionsUi = { bySession: {}, scope: null, view: null, all: false, editing: null, project: null, loadedRepo: {} }
4451
+ // The session panel's Decided list reads the log's word on each one (once per repo per visit, quietly)
4452
+ function noteSessionDecisions(s) {
4453
+ const repo = s && s.decisionRepo
4454
+ if (!repo || decisionsUi.loadedRepo[repo]) return
4455
+ decisionsUi.loadedRepo[repo] = true
4456
+ api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }).then((v) => {
4457
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
4458
+ const by = {}
4459
+ for (const d of v.decisions || []) if (d.sessionId === s.id) { by[norm(d.text)] = d; if (d.original) by[norm(d.original.text)] = d }
4460
+ decisionsUi.bySession[s.id] = by
4461
+ const box = document.querySelector('[data-fold="cp-decided"] .fs-fold-b')
4462
+ const cp = (s.checkpoints || []).filter((c) => !c.doubt).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]
4463
+ if (box && cp && cp.decided) box.innerHTML = decidedListHtml(cp.decided, { esc, log: by, repoId: repo })
4464
+ }).catch(() => { decisionsUi.loadedRepo[repo] = false })
4465
+ }
4466
+ // ---- the decision log page (#/decisions/<repo id> or #/decisions/project:<id>)
4467
+ async function viewDecisions(arg) {
4468
+ const raw = decodeURIComponent(arg || '')
4469
+ if (decisionsUi.scope !== raw) { decisionsUi.scope = raw; decisionsUi.view = null; decisionsUi.editing = null }
4470
+ decisionsUi.view = await api('GET', '/v1/decisions?' + decisionScope(raw).api)
4471
+ const want = state.route && state.route.params && state.route.params.get('d')
4472
+ if (want && (decisionsUi.view.decisions || []).some((d) => d.id === want && (d.status === 'superseded' || d.status === 'rejected'))) decisionsUi.all = true
4473
+ paintDecisions()
4474
+ if (want) { const el = document.getElementById('dec-' + want); if (el) { el.scrollIntoView({ block: 'center' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600) } }
4475
+ }
4476
+ function paintDecisions() {
4477
+ if (!decisionsUi.view || !state.route || state.route.name !== 'decisions') return
4478
+ $app.innerHTML = decisionsPageHtml(decisionsUi.view, { all: decisionsUi.all, editing: decisionsUi.editing, may: state.mayAnswer !== false, trusted: state.viewerLocal !== false || !!(state.health && state.health.answering && state.health.answering.remoteAllowed) }, { esc })
4479
+ if (decisionsUi.editing) { const i = $app.querySelector('.dec-edit .dec-in'); if (i) i.focus({ preventScroll: true }) }
4480
+ }
4481
+ async function decisionAction(repo, ref, action, body = {}) {
4482
+ try {
4483
+ await api('POST', '/v1/decisions/action', { repo, ref, action, ...body })
4484
+ decisionsUi.loadedRepo = {}
4485
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4486
+ else if (state.route && state.route.name === 'project') loadProjectDecisions(qaUi.token, true)
4487
+ } catch (e) { toast(e.message || String(e)) }
4488
+ }
4489
+ document.addEventListener('click', async (ev) => {
4490
+ const btn = ev.target.closest && ev.target.closest('[data-action^="dec-"]')
4491
+ if (!btn || btn.tagName === 'FORM') return
4492
+ const a = btn.dataset.action
4493
+ if (a === 'dec-act') return decisionAction(btn.dataset.repo, btn.dataset.id, btn.dataset.act, btn.dataset.act === 'accept' ? {} : {})
4494
+ if (a === 'dec-link') return decisionAction(btn.dataset.repo, btn.dataset.id, 'link', { supersedes: [btn.dataset.to] })
4495
+ if (a === 'dec-edit') { decisionsUi.editing = btn.dataset.id; return paintDecisions() }
4496
+ if (a === 'dec-cancel') { decisionsUi.editing = null; return paintDecisions() }
4497
+ if (a === 'dec-all') { decisionsUi.all = !decisionsUi.all; return paintDecisions() }
4498
+ if (a === 'dec-copy') { const md = decisionsUi.view && decisionsUi.view.markdown; if (md && await clipWrite(md)) toast('Copied as Markdown'); else if (md) prompt('Copy this:', md); return }
4499
+ if (a === 'dec-show') {
4500
+ const el = document.getElementById('dec-' + btn.dataset.id)
4501
+ if (el) { el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); el.classList.add('dec-flash'); setTimeout(() => el.classList.remove('dec-flash'), 1600); return }
4502
+ if (btn.dataset.repo) { location.hash = '#/decisions/' + btn.dataset.repo + '?d=' + encodeURIComponent(btn.dataset.id) }
4503
+ return
4504
+ }
4505
+ if (a === 'dec-store') {
4506
+ const on = btn.dataset.on === 'true'
4507
+ try {
4508
+ await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/decision-log', { inRepo: on })
4509
+ toast(on ? 'Accepted decisions now go into each repo’s docs/DECISIONS.md' : 'The decision log stays in Leftoff; repo files are left as they are')
4510
+ if (state.route && state.route.name === 'decisions') await viewDecisions(decisionsUi.scope)
4511
+ else loadProjectDecisions(qaUi.token, true)
4512
+ } catch (e) { toast(e.message || String(e)) }
4513
+ }
4514
+ })
4515
+ // A walkthrough step's decision chip: the decision and its chain, in the viewer (centered; a bottom sheet on a phone)
4516
+ document.addEventListener('click', (ev) => {
4517
+ const b = ev.target.closest && ev.target.closest('[data-action="wt-dec"]')
4518
+ if (!b) return
4519
+ ev.preventDefault()
4520
+ const s = wtSession(wtUi.sid)
4521
+ openDecisionViewer(b.dataset.repo || (s && s.decisionRepo) || '', b.dataset.id, b)
4522
+ })
4523
+ async function openDecisionViewer(repo, id, from) {
4524
+ if (!repo || !id) return
4525
+ let v
4526
+ try { v = await api('GET', '/v1/decisions?repo=' + encodeURIComponent(repo), undefined, { timeoutMs: 8000 }) } catch (e) { toast(e.message || String(e)); return }
4527
+ const list = v.decisions || [], byId = new Map(list.map((d) => [d.id, d]))
4528
+ const d = byId.get(id)
4529
+ const el = docViewerEl()
4530
+ el.querySelector('.dv-title').textContent = 'Decision ' + id
4531
+ el.querySelector('.dv-dl').hidden = true
4532
+ el.querySelector('.dv-ref').innerHTML = ''; el.querySelector('.dv-ptitle').textContent = ''
4533
+ el.querySelector('.dv-sub').textContent = d ? (d.repoLabel || '') : 'Not in the log any more'
4534
+ // the chain: what it replaced (oldest first), the decision, what replaced it
4535
+ const chain = []
4536
+ if (d) {
4537
+ const back = []; let cur = d, guard = 0
4538
+ while (cur && cur.supersedes && cur.supersedes.length && guard++ < 20) { const o = byId.get(cur.supersedes[0]); if (!o || back.includes(o)) break; back.unshift(o); cur = o }
4539
+ chain.push(...back, d)
4540
+ cur = d; guard = 0
4541
+ while (cur && cur.supersededBy && guard++ < 20) { const n = byId.get(cur.supersededBy); if (!n || chain.includes(n)) break; chain.push(n); cur = n }
4542
+ }
4543
+ el.querySelector('.dv-body').innerHTML = (chain.length ? '<ol class="dec-tl dec-chainview">' + chain.map((x) => decisionItemHtml(x, { esc, byId, may: false })).join('') + '</ol>' : '<p class="faint">This decision isn’t in the repo’s log.</p>') +
4544
+ '<p><a class="linkish dec-open" href="' + esc(decisionsHref(repo)) + '?d=' + encodeURIComponent(id) + '">Open the decision log →</a></p>'
4545
+ el.classList.remove('dv-isref'); el.classList.add('dv-isdec')
4546
+ refUi.from = from || null
4547
+ el.hidden = false
4548
+ document.documentElement.classList.add('dv-open')
4549
+ const x = el.querySelector('.dv-x'); if (x) x.focus({ preventScroll: true })
4550
+ }
4551
+ document.addEventListener('submit', (ev) => {
4552
+ const f = ev.target.closest && ev.target.closest('form[data-action="dec-save"]')
4553
+ if (!f) return
4554
+ ev.preventDefault()
4555
+ const text = f.querySelector('[name="text"]').value, why = f.querySelector('[name="why"]').value
4556
+ decisionsUi.editing = null
4557
+ decisionAction(f.dataset.repo, f.dataset.id, 'edit', { text, why })
4558
+ })
4559
+ // The project page's Decisions section
4560
+ async function loadProjectDecisions(token, force = false) {
4561
+ if (!token) return
4562
+ try {
4563
+ const v = await api('GET', '/v1/decisions?project=' + encodeURIComponent(token), undefined, { timeoutMs: 8000 })
4564
+ decisionsUi.project = { token, v }
4565
+ paintProjectDecisions()
4566
+ } catch { decisionsUi.project = null }
4567
+ }
4568
+ function paintProjectDecisions() {
4569
+ const box = document.getElementById('projDecisionsBox')
4570
+ const pd = decisionsUi.project
4571
+ if (!box || !pd || pd.token !== qaUi.token) return
4572
+ box.innerHTML = projectDecisionsHtml(pd.v, { esc, may: state.mayAnswer !== false, scopeArg: 'project:' + ((pd.v.scope && pd.v.scope.id) || pd.token) })
4573
+ }
4429
4574
  // 2026-10-29 The panel's newest checkpoint: Stopped + Next; Goal, Doing, the extra sections,
4430
4575
  // your note and the files behind More (open or closed as you last left it on this device).
4431
4576
  function cpLatestHtml(cp, wtSid, paths) {
@@ -6629,6 +6774,7 @@ async function render() {
6629
6774
  else if (r.name === 'search') await viewSearch(r.q || '')
6630
6775
  else if (r.name === 'new') await viewNewSession()
6631
6776
  else if (r.name === 'project' && r.arg) await viewProject(r.arg)
6777
+ else if (r.name === 'decisions' && r.arg) await viewDecisions(location.hash.replace(/^#\/decisions\//, '').split('?')[0])
6632
6778
  else if (r.name === 'ask' && r.arg) await viewAsk(r.arg, r.params.get('m'))
6633
6779
  else if (r.name === 'setup') await viewSetup()
6634
6780
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
@@ -6663,6 +6809,7 @@ async function viewProject(token) {
6663
6809
  const [page] = await Promise.all([api('GET', '/v1/project-page?scope=' + encodeURIComponent(token)), loadProjects()]) // 0.9.12: the projects, for the title's scope menu
6664
6810
  qaUi.page = page
6665
6811
  paintProject()
6812
+ if (page && page.scope && page.scope.kind === 'project') loadProjectDecisions(token)
6666
6813
  if (fresh) window.scrollTo(0, 0)
6667
6814
  const r = state.route || {}
6668
6815
  if (sAskUi.carry && sAskUi.carry.token === token) { // 0.9.11: "Whole project" from a session's Ask carries the question here
@@ -6682,6 +6829,11 @@ function paintProject() {
6682
6829
  const kf = fa && fa.id === 'askIn' ? { a: fa.selectionStart, b: fa.selectionEnd } : null
6683
6830
  $app.innerHTML = out.html
6684
6831
  fillAsk($app, out.keys)
6832
+ if (p.scope && p.scope.kind === 'project') { // 2026-11-03 decision memory: the project's log and where it's kept
6833
+ const at = document.getElementById('projAsks')
6834
+ if (at && !document.getElementById('projDecisionsBox')) { const box = document.createElement('div'); box.id = 'projDecisionsBox'; at.after(box) }
6835
+ paintProjectDecisions()
6836
+ }
6685
6837
  const ta = document.getElementById('askIn')
6686
6838
  if (ta) { ta.value = state.drafts['ask:' + ((p.scope || {}).key || '')] || ''; fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
6687
6839
  }
@@ -9521,7 +9673,7 @@ function docViewerEl() {
9521
9673
  document.body.appendChild(el)
9522
9674
  return el
9523
9675
  }
9524
- function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9676
+ function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.classList.remove('dv-isdec'); el.querySelector('.dv-body').innerHTML = ''; const back = el.classList.contains('dv-isref') ? refUi.from : null; el.classList.remove('dv-isref'); refUi.sid = null; refUi.from = null; refUi.r = null; if (back && back.isConnected) back.focus({ preventScroll: true }) } docUi.r = null; document.documentElement.classList.remove('dv-open') }
9525
9677
  document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !escInTerminal(ev) && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
9526
9678
  async function openDoc(sessionId, { path, plan } = {}) {
9527
9679
  if (!sessionId || (!path && !plan)) return
package/ui/ask.js CHANGED
@@ -19,7 +19,7 @@ import { glyphState, glyphHtml } from './sglyph.js' // 0.9.14: the status glyph,
19
19
  export const projectHref = (token) => '#/project/' + encodeURIComponent(String(token || ''))
20
20
  export const threadHref = (id, machine = null) => '#/ask/' + encodeURIComponent(String(id || '')) + (machine ? '?m=' + encodeURIComponent(machine) : '')
21
21
 
22
- const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2})\]/g
22
+ const TAG = /\[((?:s|cp)\d{1,3}|c:[0-9a-f]{7}|d:\d{4}-\d{2}-\d{2}|dec:D-[0-9a-f]{4,12})\]/g
23
23
 
24
24
  // Where a citation chip goes: a session, a checkpoint on its session page,
25
25
  // a digest day; a commit has no page here (its sha is copied).
@@ -28,15 +28,17 @@ export function chipHref(c) {
28
28
  if (c.kind === 'session' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId)
29
29
  if (c.kind === 'checkpoint' && c.sessionId) return '#/s/' + encodeURIComponent(c.sessionId) + (c.cpId ? '?cp=' + encodeURIComponent(c.cpId) : '')
30
30
  if (c.kind === 'digest' && c.date) return '#/digest/' + encodeURIComponent(c.date)
31
+ if (c.kind === 'decision' && c.repo && c.id) return '#/decisions/' + String(c.repo).replace(/[^\w:./@-]/g, encodeURIComponent) + '?d=' + encodeURIComponent(c.id) // 2026-11-03
31
32
  return null
32
33
  }
33
34
  export function chipText(c) {
34
35
  if (!c) return ''
35
36
  if (c.kind === 'commit') return (c.short || String(c.sha || '').slice(0, 7)) + (c.label ? ' ' + c.label : '')
36
37
  if (c.kind === 'digest') return 'Digest ' + (c.date || '')
38
+ if (c.kind === 'decision') return (c.id || 'Decision') + (c.label ? ' ' + c.label : '')
37
39
  return c.label || (c.kind === 'checkpoint' ? 'Checkpoint' : 'Session')
38
40
  }
39
- const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper' }
41
+ const KIND_ICON = { session: 'history', checkpoint: 'check', commit: 'copy', digest: 'newspaper', decision: 'route' }
40
42
 
41
43
  // One chip. Its words are filled in as text (data-qt).
42
44
  export function chipHtml(c, key, { esc, icon = () => '', n = null } = {}) {
@@ -0,0 +1,133 @@
1
+ // Decision memory (2026-11-03): pure builders for the board. A checkpoint's
2
+ // Decided list (the session panel), a repo's or a project's decision log (the
3
+ // timeline), and the decision chips a walkthrough step cites. app.js wires
4
+ // them; unit-tested in test/unit/decisions-ui-1103.test.mjs.
5
+
6
+ const escD = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
7
+ const day = (iso) => String(iso || '').slice(0, 10)
8
+ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', correction: 'Your correction', agent: 'Agent chose' }
9
+ // Status as a shape and a word, never color alone.
10
+ export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
+ export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
+
13
+ // A checkpoint's decisions, under the newest checkpoint in the session panel.
14
+ // log: the session's decisions from its repo's log, when loaded ({ <text norm>: { id, status } }).
15
+ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null } = {}) {
16
+ const list = Array.isArray(decided) ? decided : []
17
+ if (!list.length) return ''
18
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
19
+ const rows = list.map((d) => {
20
+ const hit = log && log[norm(d.text)]
21
+ const st = hit ? '<span class="dec-st dec-st-' + esc(hit.status) + '">' + STATUS_SHAPE[hit.status] + ' ' + esc(STATUS_WORD[hit.status]) + '</span>' : ''
22
+ return '<li class="dec-row">' + (hit ? '<span class="dec-id">' + esc(hit.id) + '</span> ' : '') + '<span class="dec-text">' + esc(d.text) + '</span>' + st +
23
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
24
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
25
+ '<div class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</div></li>'
26
+ }).join('')
27
+ const link = repoId ? '<a class="linkish dec-open" href="' + esc(decisionsHref(repoId)) + '">Decision log →</a>' : ''
28
+ return '<ul class="dec-list">' + rows + '</ul>' + link
29
+ }
30
+
31
+ // ------------------------------------------------------------ the decision log (timeline)
32
+
33
+ // A route's scope: "#/decisions/gh:acme/web" (a repo) or "#/decisions/project:acme" (a project's repos, merged).
34
+ export function decisionScope(arg) {
35
+ const a = String(arg || '')
36
+ if (a.startsWith('project:')) return { project: a.slice(8), repo: null, api: 'project=' + encodeURIComponent(a.slice(8)) }
37
+ return { project: null, repo: a, api: 'repo=' + encodeURIComponent(a) }
38
+ }
39
+ export const decisionsHref = (scope) => '#/decisions/' + encodeURIComponent(scope).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
40
+
41
+ // What the timeline shows: current decisions (accepted), oldest first, unless you asked for all of them
42
+ // (superseded and rejected too). Proposed ones are always above, to review.
43
+ export function timelineRows(decisions, { all = false } = {}) {
44
+ const list = Array.isArray(decisions) ? decisions : []
45
+ return {
46
+ review: list.filter((d) => d.status === 'proposed'),
47
+ rows: list.filter((d) => d.status === 'accepted' || (all && (d.status === 'superseded' || d.status === 'rejected'))),
48
+ hidden: list.filter((d) => d.status === 'superseded' || d.status === 'rejected').length,
49
+ }
50
+ }
51
+
52
+ function sessionBit(d, esc) {
53
+ const s = d.session
54
+ if (!s || !s.id) return ''
55
+ const title = s.title || 'its session'
56
+ return '<a class="linkish dec-sess" href="#/s/' + esc(encodeURIComponent(s.id)) + '">From ' + esc(title) + '</a>' +
57
+ (s.pr && s.pr.url && /^https:\/\//.test(s.pr.url) ? ' · <a class="linkish dec-pr" href="' + esc(s.pr.url) + '" target="_blank" rel="noopener">PR' + (s.pr.number ? ' #' + esc(s.pr.number) : '') + '</a>' : '')
58
+ }
59
+ function chainBit(d, byId, esc) {
60
+ const out = []
61
+ for (const id of d.supersedes || []) { const o = byId.get(id); out.push('<span class="dec-chain">Replaces <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>' + (o ? ' (' + esc(day(o.createdAt)) + ')' : '') + '</span>') }
62
+ if (d.supersededBy) out.push('<span class="dec-chain">Superseded by <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(d.supersededBy) + '">' + esc(d.supersededBy) + '</button> on ' + esc(day(d.supersededAt)) + '</span>')
63
+ return out.length ? '<div class="dec-chains">' + out.join('') + '</div>' : ''
64
+ }
65
+ function statusBit(d, esc) {
66
+ return '<span class="dec-st dec-st-' + esc(d.status) + '"><span aria-hidden="true">' + STATUS_SHAPE[d.status] + '</span> ' + esc(STATUS_WORD[d.status]) + '</span>'
67
+ }
68
+ function editForm(d, esc) {
69
+ return '<form class="dec-edit" data-action="dec-save" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' +
70
+ '<label>Decision<input class="dec-in" name="text" maxlength="240" value="' + esc(d.text) + '" autocomplete="off"></label>' +
71
+ '<label>Why<input class="dec-in" name="why" maxlength="240" value="' + esc(d.why || '') + '" autocomplete="off"></label>' +
72
+ '<div class="dec-acts"><button type="submit" class="btn primary">Save</button><button type="button" class="btn" data-action="dec-cancel" data-id="' + esc(d.id) + '">Cancel</button></div></form>'
73
+ }
74
+
75
+ // One decision. may: you can change it (the board's usual gate); editing: its id is being edited.
76
+ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true, editing = null, multi = false, review = false } = {}) {
77
+ const repo = multi && d.repoLabel ? '<span class="dec-repo">' + esc(d.repoLabel) + '</span>' : ''
78
+ const head = '<div class="dec-h"><span class="dec-date">' + esc(day(d.createdAt)) + '</span><span class="dec-id">' + esc(d.id) + '</span>' + repo + statusBit(d, esc) + '</div>'
79
+ if (editing === d.id) return '<li class="dec-item" id="dec-' + esc(d.id) + '">' + head + editForm(d, esc) + '</li>'
80
+ const sug = (d.suggests || []).map((id) => { const o = byId.get(id); return o ? '<div class="dec-sug"><span>Changes <button type="button" class="dec-chip" data-action="dec-show" data-id="' + esc(id) + '">' + esc(id) + '</button>: ' + esc(o.text) + '</span>' +
81
+ (may ? '<span class="dec-acts"><button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(id) + '" data-repo="' + esc(d.repo) + '">Confirm it replaces ' + esc(id) + '</button><button type="button" class="btn" data-action="dec-act" data-act="dismiss" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">Not related</button></span>' : '') + '</div>' : '' }).join('')
82
+ const btn = (act, label, cls = '') => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '">' + label + '</button>'
83
+ const acts = !may ? '' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button></div>'
84
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : '<button type="button" class="linkish" data-action="dec-edit" data-id="' + esc(d.id) + '">Edit</button>') + '</div>'
85
+ return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
86
+ '<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
87
+ (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
88
+ (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
89
+ chainBit(d, byId, esc) + sug +
90
+ '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
91
+ }
92
+
93
+ // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
94
+ export function storageChoiceHtml(project, { esc = escD, may = true } = {}) {
95
+ if (!project) return '<p class="faint dec-local">This repo isn’t in a project, so its log stays in Leftoff only.</p>'
96
+ const on = !!project.inRepo
97
+ const opt = (v, label) => '<button type="button" class="sask-opt" data-action="dec-store" data-project="' + esc(project.id) + '" data-on="' + v + '" aria-pressed="' + (on === v) + '"' + (may ? '' : ' disabled') + '>' + label + '</button>'
98
+ return '<div class="dec-store"><div class="dec-store-h">Keep decision log in this repo</div>' +
99
+ '<div class="sask-scope dec-store-opts" role="group" aria-label="Keep decision log in this repo">' + opt(false, 'Leftoff only') + opt(true, 'Also in each repo') + '</div>' +
100
+ '<p class="faint">' + (on ? 'Accepted decisions are written to ' + esc(project.file || 'docs/DECISIONS.md') + ' in each repo of ' + esc(project.name) + ', between Leftoff’s markers, on a Mac with a checkout. Never staged or committed: you commit it or not.'
101
+ : 'The log stays in Leftoff’s folder, shared by your Macs. Turn this on to also write accepted decisions into each repo’s ' + esc(project.file || 'docs/DECISIONS.md') + '.') + '</p></div>'
102
+ }
103
+
104
+ // The whole page. v: GET /v1/decisions. ui: { all, editing, may }
105
+ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
106
+ const list = (v && v.decisions) || []
107
+ const byId = new Map(list.map((d) => [d.id, d]))
108
+ const multi = ((v && v.repos) || []).length > 1
109
+ const { review, rows, hidden } = timelineRows(list, { all: !!ui.all })
110
+ const sc = (v && v.scope) || {}
111
+ const name = sc.kind === 'project' ? sc.name : sc.label
112
+ const opts = { esc, byId, may: ui.may !== false, editing: ui.editing || null, multi }
113
+ let h = '<a class="back" href="' + (sc.kind === 'project' ? '#/project/' + esc(encodeURIComponent(sc.id)) : '#/') + '">← ' + (sc.kind === 'project' ? esc(name) : 'All sessions') + '</a>'
114
+ h += '<header class="dec-head"><h1>Decisions <span class="faint">· ' + esc(name || '') + '</span></h1>' +
115
+ (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
116
+ if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false && ui.trusted !== false }) + '</section>'
117
+ if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
118
+ const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
119
+ '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button></div>'
120
+ h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
121
+ (rows.length ? '<ol class="dec-tl">' + rows.map((d) => decisionItemHtml(d, opts)).join('') + '</ol>' : '<p class="faint">' + (review.length ? 'Nothing accepted yet.' : 'No decisions yet. They’re proposed here when a checkpoint sees one: a question you answered, a plan you approved, a correction.') + '</p>') + '</section>'
122
+ return '<div class="proj dec-page">' + h + '</div>'
123
+ }
124
+
125
+ // The project page's short Decisions section: counts, the storage choice, the way in.
126
+ export function projectDecisionsHtml(v, { esc = escD, may = true, scopeArg = '' } = {}) {
127
+ if (!v) return ''
128
+ const list = v.decisions || []
129
+ const cur = list.filter((d) => d.status === 'accepted').length, rev = list.filter((d) => d.status === 'proposed').length
130
+ return '<section class="proj-sec dec-proj" id="projDecisions"><h2>Decisions <span class="count">' + cur + '</span></h2>' +
131
+ '<p>' + (cur ? cur + ' current' : 'None accepted yet') + (rev ? ' · <strong>' + rev + ' to review</strong>' : '') + ' · <a class="linkish" href="' + esc(decisionsHref(scopeArg)) + '">Open the timeline →</a></p>' +
132
+ storageChoiceHtml(v.project, { esc, may }) + '</section>'
133
+ }
package/ui/hook-fix.js ADDED
@@ -0,0 +1,109 @@
1
+ // 2026-11-03 "Let me answer prompts here": the hooks-skipped note's way out.
2
+ // Claude Code's Security default runs ahead of the plugins you install (Team or
3
+ // Enterprise, managed settings, a managed MCP file) and sends Stop and
4
+ // permission prompts on past Leftoff. Claude Code ignores appendPlugins in
5
+ // your own ~/.claude/settings.json while it's seated (2.1.317), so the sheet
6
+ // shows the managed-settings lines and where they go, with Copy; Leftoff
7
+ // writes none of it. The one change it can make: take Leftoff's own entry
8
+ // back out of your settings when Claude Code ignores it there (an explicit
9
+ // Remove, this Mac's own browser or a trusted paired device, never on a
10
+ // managed Mac). Another Mac's session: run leftoff doctor --fix there.
11
+ // Centered on a desktop, a bottom sheet on a phone.
12
+
13
+ export function hooksFixButtonHtml(s, { esc }) {
14
+ const k = s && s.hooksSkipped
15
+ if (!k || !k.label || k.applied || s.endedAt) return ''
16
+ return '<button type="button" class="s-hooks-fix" data-hookfix="' + esc(s.id) + '" data-tip="Why Leftoff can’t answer this session’s prompts from the board, and the settings line that lets it">Let me answer prompts here</button>'
17
+ }
18
+
19
+ let open = null
20
+
21
+ export function closeHookFix() {
22
+ if (!open) return
23
+ const { back, from, onKey, sid } = open
24
+ open = null
25
+ document.removeEventListener('keydown', onKey, true)
26
+ back.remove()
27
+ document.documentElement.classList.remove('hf-open')
28
+ // the header may have been redrawn while the sheet was up: its button for this session now
29
+ const to = from && from.isConnected ? from : sid ? document.querySelector('[data-hookfix="' + CSS.escape(sid) + '"]') : null
30
+ if (to) try { to.focus({ preventScroll: true }) } catch {}
31
+ }
32
+
33
+ const copyText = async (t) => { try { await navigator.clipboard.writeText(t); return true } catch { return false } }
34
+
35
+ // st: GET /v1/hook-order. -> the sheet's body
36
+ export function hookFixBodyHtml(st, { esc, session = null, thisMachine = null } = {}) {
37
+ if (session && session.isLocal === false) {
38
+ const mac = session.machine || 'that Mac'
39
+ return '<p class="hf-p">This session runs on <b>' + esc(mac) + '</b>, and the settings that decide the plugin order are that Mac’s. Run this there; it says what to add, and why:</p>' +
40
+ '<div class="hf-code-row"><pre class="hf-code hf-cmd">leftoff doctor --fix</pre></div>' +
41
+ '<div class="hf-acts"><button type="button" class="btn hf-copy" data-copy="leftoff doctor --fix">Copy</button><button type="button" class="btn primary hf-close">Close</button></div>'
42
+ }
43
+ if (!st || st.error) return '<p class="hf-p">Couldn’t read this Mac’s Claude Code settings' + (st && st.error ? ' (' + esc(st.error) + ')' : '') + '. <code>leftoff doctor --fix</code> in a terminal says the same.</p><div class="hf-acts"><button type="button" class="btn primary hf-close">Close</button></div>'
44
+ let h = ''
45
+ if (!st.seated) {
46
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span>Nothing here puts a plugin ahead of Leftoff’s hooks' + (thisMachine ? ' on ' + esc(thisMachine) : '') + ' right now. A session that started before a change may still show the note; restart Claude Code there.</p>'
47
+ } else if (st.placed) {
48
+ h += '<p class="hf-p"><span class="hf-mark hf-mark-ok" aria-hidden="true"></span><b>Managed settings already seat Leftoff</b> right after Claude Code’s Security default. Restart Claude Code sessions that started before that, and their prompts come here.</p>'
49
+ } else {
50
+ h += '<p class="hf-p">' + esc(st.words) + '</p>'
51
+ h += '<p class="hf-p hf-who"><span class="hf-mark hf-mark-admin" aria-hidden="true"></span>' + (st.managed ? 'Ask whoever manages Claude Code here to ' : 'An admin (on your own Mac, that’s you, with sudo) can ') + (st.snippet.merge ? 'merge this into' : 'put this in') + ':</p>'
52
+ h += '<div class="hf-file"><code>' + esc(st.snippet.file) + '</code></div>'
53
+ h += '<pre class="hf-code">' + esc(st.snippet.json) + '</pre>'
54
+ h += '<p class="hf-p hf-quiet">' + esc(st.snippet.note) + '. Then restart Claude Code. Leftoff never writes managed settings.</p>'
55
+ }
56
+ if (st.ignoredEntry) {
57
+ h += '<div class="hf-ign"><p class="hf-p"><span class="hf-mark hf-mark-off" aria-hidden="true"></span>Your <code>' + esc(st.ignoredEntry.file) + '</code> lists Leftoff in ' + esc(st.ignoredEntry.keys.join(' and ')) + ', which Claude Code ignores here. Removing it changes nothing in Claude Code.</p>' +
58
+ (st.canUnfix ? '<p class="hf-p hf-quiet">Remove takes out only Leftoff’s entry and keeps a backup in ~/.config/checkpoints/backups.</p>' : '<p class="hf-p hf-quiet">This Mac has managed settings, so Leftoff changes nothing in ~/.claude; delete the entry by hand if you like.</p>') + '</div>'
59
+ }
60
+ h += '<div class="hf-acts">' +
61
+ (st.ignoredEntry && st.canUnfix ? '<button type="button" class="btn hf-unfix">Remove the ignored entry</button>' : '') +
62
+ (st.seated && !st.placed ? '<button type="button" class="btn hf-copy" data-copy="' + esc(st.snippet.json) + '">Copy</button>' : '') +
63
+ '<button type="button" class="btn primary hf-close">' + (st.seated && !st.placed ? 'Done' : 'Close') + '</button></div>'
64
+ return h
65
+ }
66
+
67
+ export async function openHookFix({ session, from = null, api, esc, toast = () => {}, thisMachine = null } = {}) {
68
+ closeHookFix()
69
+ const back = document.createElement('div')
70
+ back.className = 'hf-back'
71
+ back.innerHTML = '<div class="hf-modal" role="dialog" aria-modal="true" aria-labelledby="hfTitle"><div class="hf-grab" aria-hidden="true"></div>' +
72
+ '<div class="hf-head"><h2 id="hfTitle">Let Leftoff answer prompts here</h2><button type="button" class="btn x hf-x" aria-label="Close">✕</button></div>' +
73
+ '<div class="hf-body"><p class="hf-p hf-quiet">Reading this Mac’s Claude Code settings…</p></div></div>'
74
+ document.body.appendChild(back)
75
+ document.documentElement.classList.add('hf-open')
76
+ const onKey = (e) => {
77
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeHookFix(); return }
78
+ if (e.key === 'Tab') { // keep focus inside
79
+ const f = [...back.querySelectorAll('button:not([disabled])')]
80
+ if (!f.length) return
81
+ const i = f.indexOf(document.activeElement)
82
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus() }
83
+ }
84
+ }
85
+ open = { back, from, onKey, sid: session && session.id }
86
+ document.addEventListener('keydown', onKey, true)
87
+ const body = back.querySelector('.hf-body')
88
+ const paint = (st) => {
89
+ body.innerHTML = hookFixBodyHtml(st, { esc, session, thisMachine })
90
+ const first = back.querySelector('.hf-copy, .hf-close')
91
+ if (first) try { first.focus({ preventScroll: true }) } catch {}
92
+ }
93
+ back.addEventListener('click', async (e) => {
94
+ if (e.target === back || e.target.closest('.hf-x, .hf-close')) { closeHookFix(); return }
95
+ const c = e.target.closest('.hf-copy')
96
+ if (c) { const ok = await copyText(c.dataset.copy || ''); c.textContent = ok ? 'Copied' : 'Select and copy'; setTimeout(() => { if (c.isConnected) c.textContent = 'Copy' }, 1800); return }
97
+ const u = e.target.closest('.hf-unfix')
98
+ if (u) {
99
+ u.disabled = true; u.textContent = 'Removing…'
100
+ try {
101
+ const r = await api('POST', '/v1/hook-order/unfix', { yes: true })
102
+ paint(r.state)
103
+ toast(r.wrote ? 'Removed Leftoff’s entry from ' + r.file + (r.backup ? ' (backup kept)' : '') + '. Restart open Claude Code sessions.' : 'Nothing to remove.', 5000)
104
+ } catch (err) { u.disabled = false; u.textContent = 'Remove the ignored entry'; toast((err && err.message) || 'Couldn’t change it.', 5000) }
105
+ }
106
+ })
107
+ if (session && session.isLocal === false) { paint(null); return }
108
+ try { paint(await api('GET', '/v1/hook-order?fresh=1')) } catch (err) { paint({ error: (err && err.message) || 'no answer' }) }
109
+ }