@mjasnikovs/pi-task 0.17.24 → 0.17.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +18 -1
- package/dist/shared/child-process.d.ts +5 -0
- package/dist/shared/child-process.js +2 -1
- package/dist/task/auto-commit.d.ts +15 -0
- package/dist/task/auto-commit.js +47 -3
- package/dist/task/auto-orchestrator.d.ts +24 -0
- package/dist/task/auto-orchestrator.js +82 -3
- package/dist/task/final-gate.d.ts +23 -0
- package/dist/task/final-gate.js +143 -0
- package/dist/task/gate-deps.js +49 -15
- package/dist/task/git-state-guard.d.ts +44 -0
- package/dist/task/git-state-guard.js +240 -0
- package/dist/task/orchestrator.d.ts +8 -0
- package/dist/task/orchestrator.js +1 -1
- package/dist/task/phases.d.ts +5 -0
- package/dist/task/phases.js +30 -4
- package/dist/task/task-gates.js +15 -4
- package/dist/task/verify-work.d.ts +12 -0
- package/dist/task/verify-work.js +14 -0
- package/dist/workers/search-extension.d.ts +2 -0
- package/dist/workers/search-extension.js +6 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right (the surface bar that used to live on .code-lang). */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps (clamped to 2) \u2014 never truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n .widget-bar-fill { height: 100%; background: var(--mauve); border-radius: 2px;\n transition: width 0.3s ease; }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n }";
|
|
1
|
+
export declare const STYLES = " :root {\n --base: #1e1e2e; --mantle: #181825; --crust: #11111b;\n --surface0: #313244; --surface1: #45475a; --surface2: #585b70;\n --text: #cdd6f4; --subtext1: #a6adc8; --subtext0: #7f849c;\n --mauve: #cba6f7; --blue: #89b4fa; --green: #a6e3a1; --red: #f38ba8;\n --yellow: #f9e2af; --peach: #fab387; --teal: #94e2d5;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n background: var(--base); color: var(--text);\n font-family: ui-monospace, monospace;\n /* --app-h is set from window.innerHeight (see setAppHeight) so the column\n height is a stable pixel value across an orientation change. 100dvh is a\n fallback for first paint / no-JS: iOS Safari interpolates dvh during the\n rotation animation, which makes the whole flex column resize repeatedly\n (\"spazzing out\") \u2014 a fixed px height does not. */\n height: var(--app-h, 100dvh);\n display: flex; flex-direction: column; overflow: hidden;\n padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)\n 0px env(safe-area-inset-left, 0px);\n }\n #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }\n #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }\n #header {\n background: var(--mantle); padding: 8px 16px;\n display: flex; justify-content: space-between; align-items: center;\n font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);\n }\n #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;\n position: relative; animation: glitch 5s steps(1) infinite; }\n @keyframes glitch {\n 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }\n 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }\n 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }\n 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }\n 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }\n }\n @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }\n #header .hgroup { display: flex; align-items: center; gap: 10px; }\n #bell {\n background: none; border: none; color: var(--subtext1); cursor: pointer;\n font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;\n }\n #bell:hover { color: var(--text); }\n #bell.on { color: var(--mauve); }\n /* Header status chip: connection dot + model name + context usage. */\n #status-chip { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--subtext0); }\n #status-dot {\n width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;\n background: var(--surface2); transition: background 0.2s ease;\n }\n #status-dot.idle { background: var(--green); }\n #status-dot.running { background: var(--mauve); animation: dot-pulse 1.2s ease-in-out infinite; }\n #status-dot.disconnected { background: var(--red); }\n @keyframes dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }\n @media (prefers-reduced-motion: reduce) { #status-dot.running { animation: none; } }\n #status-model { color: var(--subtext1); }\n #status-model:empty { display: none; }\n #status-ctx { color: var(--subtext0); font-variant-numeric: tabular-nums; }\n #status-ctx:empty { display: none; }\n /* Notification bell dropdown: a push toggle row + the recent-toast history. */\n #notif-panel {\n display: none; position: fixed; z-index: 80;\n top: calc(env(safe-area-inset-top, 0px) + 42px);\n right: calc(env(safe-area-inset-right, 0px) + 12px);\n width: min(320px, calc(100vw - 24px));\n background: var(--mantle); border: 1px solid var(--surface1); border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); overflow: hidden;\n }\n #notif-panel.open { display: block; }\n #notif-toggle-row {\n display: flex; align-items: center; justify-content: space-between;\n padding: 8px 12px; border-bottom: 1px solid var(--surface0);\n }\n #notif-title { font-size: 12px; color: var(--subtext1); font-weight: 700; }\n #notif-toggle {\n background: var(--surface1); color: var(--text); border: none; border-radius: 6px;\n padding: 4px 10px; font-family: inherit; font-size: 11px; cursor: pointer;\n }\n #notif-toggle:hover { filter: brightness(1.1); }\n #notif-toggle.on { background: var(--mauve); color: var(--crust); font-weight: 700; }\n #notif-list { max-height: 40dvh; overflow-y: auto; }\n #notif-empty { padding: 14px 12px; color: var(--subtext0); font-size: 11px; text-align: center; }\n .notif-item {\n display: flex; align-items: baseline; gap: 8px; padding: 7px 12px;\n border-bottom: 1px solid var(--surface0); font-size: 12px;\n }\n .notif-item:last-child { border-bottom: none; }\n .notif-item .notif-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--blue); align-self: center; }\n .notif-item.warning .notif-dot { background: var(--peach); }\n .notif-item.error .notif-dot { background: var(--red); }\n .notif-item .notif-msg { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; word-break: break-word; }\n .notif-item .notif-time { flex-shrink: 0; color: var(--subtext0); font-size: 10px; font-variant-numeric: tabular-nums; }\n #chat-wrap { position: relative; flex: 1; min-height: 0; display: flex; }\n #chat-log {\n flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;\n display: flex; flex-direction: column; gap: 8px;\n }\n /* Floating jump-to-latest button \u2014 only shown when scrolled away from the\n bottom (toggled via .visible from the scroll handler). */\n #scroll-bottom {\n display: none; position: absolute; bottom: 16px; right: 16px; z-index: 40;\n width: 36px; height: 36px; border-radius: 50%; cursor: pointer;\n background: var(--surface1); color: var(--text); border: 1px solid var(--surface2);\n font-family: inherit; font-size: 18px; line-height: 1; padding: 0;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n }\n #scroll-bottom:hover { background: var(--surface2); color: var(--mauve); }\n #scroll-bottom.visible { display: block; }\n #chat-log::-webkit-scrollbar { width: 6px; }\n #chat-log::-webkit-scrollbar-track { background: transparent; }\n #chat-log::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }\n .bubble {\n max-width: 82%; padding: 8px 12px; border-radius: 8px;\n line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;\n }\n .bubble.user { background: var(--surface1); color: var(--text); align-self: flex-end; }\n .bubble.assistant { background: var(--surface0); color: var(--text); align-self: flex-start; position: relative; }\n .bubble.error {\n background: var(--crust); color: var(--red); align-self: stretch;\n max-width: 100%; border: 1px solid var(--red); font-size: 12px;\n }\n /* Persistent inline system note (e.g. context compaction) \u2014 a muted centered\n divider, distinct from chat bubbles. */\n .sysnote {\n align-self: center; color: var(--subtext0); font-size: 11px;\n font-family: ui-monospace, monospace; letter-spacing: 0.5px;\n padding: 2px 10px; opacity: 0.85;\n }\n .bubble.thinking {\n display: flex; gap: 5px; align-items: center; padding: 10px 14px;\n }\n .bubble.thinking .spinner {\n color: var(--mauve); font-size: 15px; line-height: 1;\n font-family: ui-monospace, monospace;\n }\n /* Collapsed reasoning block (\"\u273B Thinking\u2026 (n lines)\"), muted + italic. */\n .thinking-block { align-self: flex-start; max-width: 90%; font-size: 12px; }\n .thinking-block > summary {\n color: var(--subtext0); font-style: italic; cursor: pointer; list-style: none;\n user-select: none; display: flex; align-items: center; gap: 8px; padding: 2px 0;\n }\n .thinking-block > summary::-webkit-details-marker { display: none; }\n .thinking-block .thinking-spin {\n color: var(--mauve); font-style: normal; font-family: ui-monospace, monospace;\n }\n .thinking-block .thinking-body {\n color: var(--subtext0); font-style: italic; white-space: pre-wrap;\n word-break: break-word; line-height: 1.5; margin: 4px 0 0 4px;\n padding: 4px 0 2px 12px; border-left: 2px solid var(--surface1);\n }\n /* Copy buttons: on code-block headers and (floating) on finished assistant\n bubbles. Wired by one delegated click handler in the client script. */\n .copy-btn {\n background: transparent; border: none; color: var(--subtext0); cursor: pointer;\n font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;\n line-height: 1.4;\n }\n .copy-btn:hover { color: var(--text); background: var(--surface1); }\n .copy-btn.copied { color: var(--green); }\n .bubble-copy {\n position: absolute; top: 4px; right: 4px; opacity: 0;\n background: var(--surface1); transition: opacity 0.12s ease;\n }\n .bubble.assistant:hover .bubble-copy { opacity: 1; }\n /* Touch devices have no hover \u2014 keep the button faintly visible. */\n @media (hover: none) { .bubble-copy { opacity: 0.55; } }\n .tool-call {\n background: var(--crust); border-radius: 6px; align-self: flex-start;\n max-width: 90%; font-size: 12px; border: 1px solid var(--surface0);\n }\n .tool-call summary {\n padding: 6px 10px; color: var(--subtext1); cursor: pointer;\n user-select: none; list-style: none;\n display: flex; align-items: center; gap: 8px;\n }\n .tool-call summary::-webkit-details-marker { display: none; }\n .tool-call summary::before { content: \"\u25B6\"; flex-shrink: 0; font-size: 9px; color: var(--subtext0); }\n .tool-call[open] > summary::before { content: \"\u25BC\"; }\n .tool-call.error > summary { color: var(--red); }\n /* The summary is a single line: the label ellipsizes (full text in the title\n tooltip / on expand), badges and timing stay pinned to the right. */\n .tool-label {\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-family: ui-monospace, monospace;\n }\n .tool-badge { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-elapsed { flex-shrink: 0; color: var(--subtext0); font-size: 11px; }\n .tool-call pre {\n padding: 8px 12px; overflow-y: auto;\n color: var(--subtext1); font-size: 11px; max-height: 280px;\n border-top: 1px solid var(--surface0);\n white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;\n }\n .tool-spin { color: var(--mauve); flex-shrink: 0; font-family: ui-monospace, monospace; font-size: 13px; }\n /* Line diff for edit/write tools (tints derived from --green/--red). */\n .tool-diff { border-top: 1px solid var(--surface0); overflow-x: auto; }\n .diff { font-family: ui-monospace, monospace; font-size: 11px; padding: 4px 0; }\n .diff-line { white-space: pre; padding: 0 10px; }\n .diff-sign { display: inline-block; width: 1ch; margin-right: 8px; color: var(--subtext0); }\n .diff-add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }\n .diff-del { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }\n .diff-ctx { color: var(--subtext1); }\n .code-block {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 6px; overflow: hidden; margin: 4px 0;\n align-self: stretch; max-width: 100%; font-size: 12px;\n }\n /* Header row above a code block: language label on the left, copy button on\n the right (the surface bar that used to live on .code-lang). */\n .code-head {\n display: flex; align-items: center; justify-content: space-between;\n background: var(--surface0);\n }\n .code-lang {\n color: var(--subtext0); font-size: 10px; padding: 3px 10px; letter-spacing: 0.05em;\n }\n .code-head .copy-btn { padding: 3px 10px; }\n .code-block code {\n display: block; padding: 10px 12px; overflow-x: auto;\n color: var(--text); white-space: pre; line-height: 1.55;\n }\n .hl-kw { color: var(--mauve); }\n .hl-str { color: var(--green); }\n .hl-cmt { color: var(--subtext0); font-style: italic; }\n .hl-num { color: var(--blue); }\n .hl-fn { color: var(--yellow); }\n /* Hand-rolled markdown, applied only to assistant bubbles + the recommendation\n panel (both get the .md class from setContent). Block elements lay themselves\n out, so switch off the container's pre-wrap for these. */\n .md { white-space: normal; }\n .md > :first-child { margin-top: 0; }\n .md > :last-child { margin-bottom: 0; }\n .md .md-h { color: var(--mauve); font-weight: 700; line-height: 1.3; margin: 10px 0 4px; }\n .md .md-h1 { font-size: 1.35em; }\n .md .md-h2 { font-size: 1.2em; }\n .md .md-h3 { font-size: 1.08em; }\n .md .md-h4, .md .md-h5, .md .md-h6 { font-size: 1em; }\n .md .md-p { margin: 6px 0; }\n .md .md-list { margin: 6px 0; padding-left: 22px; }\n .md .md-list li { margin: 2px 0; }\n .md .md-task { list-style: none; margin-left: -22px; }\n .md .md-check { color: var(--green); }\n .md .md-quote { border-left: 3px solid var(--surface2); margin: 6px 0; padding: 2px 0 2px 10px; color: var(--subtext1); }\n .md .md-hr { border: none; border-top: 1px solid var(--surface1); margin: 10px 0; }\n .md a { color: var(--blue); text-decoration: underline; }\n .md strong { color: var(--text); font-weight: 700; }\n .md em { font-style: italic; }\n .md del { color: var(--subtext0); }\n .md .md-code {\n background: var(--crust); border: 1px solid var(--surface0);\n border-radius: 4px; padding: 1px 4px; font-size: 0.92em;\n }\n .md .md-table { border-collapse: collapse; margin: 8px 0; font-size: 0.95em; display: block; overflow-x: auto; }\n .md .md-table th, .md .md-table td { border: 1px solid var(--surface1); padding: 4px 8px; text-align: left; }\n .md .md-table th { background: var(--surface0); color: var(--subtext1); }\n #input-bar {\n background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));\n display: flex; gap: 8px; flex-shrink: 0;\n border-top: 1px solid var(--surface0);\n position: relative;\n }\n #cmd-suggestions {\n display: none; position: absolute; bottom: 100%; left: 16px; right: 16px;\n background: var(--mantle); border: 1px solid var(--surface1);\n border-bottom: none; border-radius: 8px 8px 0 0;\n overflow: hidden; z-index: 10;\n }\n .cmd-item {\n display: flex; align-items: baseline; gap: 10px;\n padding: 7px 12px; cursor: pointer; font-size: 12px;\n border-bottom: 1px solid var(--surface0);\n }\n .cmd-item:last-child { border-bottom: none; }\n .cmd-item:hover, .cmd-item.active { background: var(--surface0); }\n .cmd-item .cmd-name { color: var(--blue); font-weight: bold; flex-shrink: 0; }\n .cmd-item .cmd-desc { color: var(--subtext0); }\n #input {\n flex: 1; background: var(--surface0); color: var(--text);\n border: none; border-radius: 6px; padding: 8px 12px;\n font-family: inherit; font-size: 13px; resize: none;\n outline: none; line-height: 1.5; min-height: 36px; max-height: 120px;\n }\n #input::placeholder { color: var(--subtext0); }\n #input:focus { box-shadow: 0 0 0 1px var(--mauve); }\n #send {\n background: var(--blue); color: var(--crust); border: none;\n border-radius: 6px; padding: 8px 16px; font-weight: bold;\n cursor: pointer; font-size: 13px; font-family: inherit;\n white-space: nowrap; align-self: flex-end;\n }\n #send:disabled, #input:disabled { opacity: 0.45; cursor: not-allowed; }\n /* While the agent runs, Send becomes a red Stop; the armed (tap-to-confirm)\n state brightens it and adds a halo, mirroring the prompt card's cancel. */\n #send.stop { background: var(--red); color: var(--crust); }\n #send.stop.armed {\n filter: brightness(1.12);\n box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 45%, transparent);\n }\n #reconnect-overlay {\n display: none; position: fixed; inset: 0;\n background: rgba(30,30,46,0.88); color: var(--subtext1);\n justify-content: center; align-items: center;\n font-size: 13px; z-index: 100; letter-spacing: 0.03em;\n }\n #reconnect-overlay.visible { display: flex; }\n /* Trailing stream indicator: the same braille spinner as the thinking bubble,\n inline at the end of the streaming text (not a green blinking block). */\n .cursor {\n color: var(--mauve); margin-left: 2px;\n font-family: ui-monospace, monospace;\n }\n #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);\n color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }\n /* Structured task widget (progress bar + phase badge + elapsed). Replaces the\n plain text lines when the server sends a structured data payload. */\n #status-panel.structured { white-space: normal; display: block; }\n /* Title gets its own line and wraps (clamped to 2) \u2014 never truncated to a stub\n the way the old single-row flex layout squeezed it on narrow/mobile widths. */\n .widget-title { display: -webkit-box; -webkit-box-orient: vertical;\n -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text);\n font-size: 13px; line-height: 1.3; word-break: break-word; }\n .widget-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }\n .widget-phase { flex-shrink: 0; background: var(--surface0); color: var(--mauve);\n border-radius: 4px; padding: 1px 7px; font-size: 10px; letter-spacing: 0.03em;\n text-transform: uppercase; }\n .widget-step { flex-shrink: 0; color: var(--subtext0); font-size: 11px;\n font-variant-numeric: tabular-nums; }\n .widget-elapsed { flex-shrink: 0; margin-left: auto; color: var(--subtext0);\n font-size: 11px; font-variant-numeric: tabular-nums; }\n .widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;\n overflow: hidden; margin-top: 6px; }\n /* Fill carries a soft highlight band that sweeps left\u2192right through the mauve,\n so the bar reads as \"working\" even while the step count holds still. The\n gradient tile is 2x the fill width and shifts one full period per cycle,\n so the loop is seamless. Base color stays mauve for reduced-motion. */\n .widget-bar-fill { height: 100%; border-radius: 2px;\n background: linear-gradient(90deg,\n var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,\n var(--mauve) 62%, var(--mauve) 100%);\n background-size: 200% 100%;\n box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);\n animation: bar-flow 2.4s linear infinite;\n transition: width 0.3s ease; }\n @keyframes bar-flow {\n from { background-position: 200% 0; }\n to { background-position: 0% 0; }\n }\n @media (prefers-reduced-motion: reduce) {\n .widget-bar-fill { animation: none; background: var(--mauve); }\n }\n /* Current action \u2014 the terminal's \u21B3 worker trailer, one dim ellipsized line. */\n .widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n font-variant-numeric: tabular-nums; }\n /* Dim per-turn timestamp shown under a committed turn's bubble. */\n .turn-time { font-size: 10px; color: var(--subtext0); opacity: 0.55; padding: 0 4px;\n margin-top: -2px; }\n .turn-time.user { align-self: flex-end; }\n .turn-time.assistant, .turn-time.system { align-self: flex-start; }\n .turn-time.system { align-self: center; }\n #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);\n border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));\n display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }\n #prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }\n #prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;\n font-size: 15px; line-height: 1.5; }\n #prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);\n border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }\n #prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }\n #prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;\n white-space: pre-wrap; overflow-wrap: anywhere; }\n #prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);\n border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;\n font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }\n #prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: stretch;\n flex-wrap: wrap; }\n /* Recommendation answers can be long sentences, so stack them as a readable list. */\n #prompt-card .row.stacked { flex-direction: column; align-items: stretch; }\n #prompt-card .row.stacked button { flex: none; text-align: left; }\n #prompt-card .row.stacked button.cancel { align-self: center; text-align: center; }\n #prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;\n font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }\n #prompt-card button:hover { filter: brightness(1.08); }\n #prompt-card button.primary { background: var(--green); color: var(--crust);\n font-weight: 700; flex: 1; min-width: 160px; }\n #prompt-card button.secondary { background: var(--surface1); color: var(--text);\n flex: 1; min-width: 160px; }\n #prompt-card button.cancel { margin-left: auto; align-self: center; background: transparent;\n color: var(--subtext0); font-size: 12px; font-weight: 500; padding: 8px 10px; }\n #prompt-card button.cancel:hover { color: var(--red); filter: none; }\n #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }\n .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);\n right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);\n padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;\n background: var(--surface1); color: var(--text); z-index: 60; }\n .toast.warning { background: var(--peach); color: var(--crust); }\n .toast.error { background: var(--red); color: var(--crust); }\n #viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);\n border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;\n overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }\n #viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }\n /* Desktop: center the transcript/status/input in a readable column instead of\n hugging the left edge. The scrollbar stays at the true window edge; only the\n content is inset. Mobile (below 960px) is unchanged. */\n @media (min-width: 960px) {\n #chat-log { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #status-panel { padding-left: calc((100% - 920px) / 2 + 12px); padding-right: calc((100% - 920px) / 2 + 12px); }\n #input-bar { padding-left: calc((100% - 920px) / 2); padding-right: calc((100% - 920px) / 2); }\n #cmd-suggestions { left: calc((100% - 920px) / 2 + 16px); right: calc((100% - 920px) / 2 + 16px); }\n }";
|
package/dist/remote/ui-styles.js
CHANGED
|
@@ -330,8 +330,25 @@ export const STYLES = ` :root {
|
|
|
330
330
|
font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
331
331
|
.widget-bar { height: 4px; background: var(--surface0); border-radius: 2px;
|
|
332
332
|
overflow: hidden; margin-top: 6px; }
|
|
333
|
-
|
|
333
|
+
/* Fill carries a soft highlight band that sweeps left→right through the mauve,
|
|
334
|
+
so the bar reads as "working" even while the step count holds still. The
|
|
335
|
+
gradient tile is 2x the fill width and shifts one full period per cycle,
|
|
336
|
+
so the loop is seamless. Base color stays mauve for reduced-motion. */
|
|
337
|
+
.widget-bar-fill { height: 100%; border-radius: 2px;
|
|
338
|
+
background: linear-gradient(90deg,
|
|
339
|
+
var(--mauve) 0%, var(--mauve) 38%, #ecdcfd 50%,
|
|
340
|
+
var(--mauve) 62%, var(--mauve) 100%);
|
|
341
|
+
background-size: 200% 100%;
|
|
342
|
+
box-shadow: 0 0 6px rgba(203, 166, 247, 0.45);
|
|
343
|
+
animation: bar-flow 2.4s linear infinite;
|
|
334
344
|
transition: width 0.3s ease; }
|
|
345
|
+
@keyframes bar-flow {
|
|
346
|
+
from { background-position: 200% 0; }
|
|
347
|
+
to { background-position: 0% 0; }
|
|
348
|
+
}
|
|
349
|
+
@media (prefers-reduced-motion: reduce) {
|
|
350
|
+
.widget-bar-fill { animation: none; background: var(--mauve); }
|
|
351
|
+
}
|
|
335
352
|
/* Current action — the terminal's ↳ worker trailer, one dim ellipsized line. */
|
|
336
353
|
.widget-action { margin-top: 6px; color: var(--subtext0); font-size: 11px;
|
|
337
354
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
@@ -20,6 +20,9 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
|
20
20
|
cwd: string;
|
|
21
21
|
shell: boolean;
|
|
22
22
|
stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
|
|
23
|
+
/** Set only when the invocation needs env overrides (e.g. GIT_INDEX_FILE);
|
|
24
|
+
* absent → the child inherits this process's environment as before. */
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
23
26
|
}) => ProcLike;
|
|
24
27
|
export interface ChildResult {
|
|
25
28
|
stdout: string;
|
|
@@ -113,9 +116,11 @@ export declare function runChild(spawn: SpawnFn, invocation: {
|
|
|
113
116
|
command: string;
|
|
114
117
|
args: ReadonlyArray<string>;
|
|
115
118
|
stdin?: string;
|
|
119
|
+
env?: NodeJS.ProcessEnv;
|
|
116
120
|
}, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions): Promise<ChildResult>;
|
|
117
121
|
export declare function runChildDefault(invocation: {
|
|
118
122
|
command: string;
|
|
119
123
|
args: ReadonlyArray<string>;
|
|
124
|
+
env?: NodeJS.ProcessEnv;
|
|
120
125
|
}, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions, spawnFn?: SpawnFn): Promise<ChildResult>;
|
|
121
126
|
export declare function summarizeToolArgs(toolName: string, args: unknown): string;
|
|
@@ -178,7 +178,8 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
178
178
|
const proc = spawn(invocation.command, invocation.args, {
|
|
179
179
|
cwd,
|
|
180
180
|
shell: false,
|
|
181
|
-
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
|
|
181
|
+
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
182
|
+
...(invocation.env ? { env: invocation.env } : {})
|
|
182
183
|
});
|
|
183
184
|
if (usesStdin) {
|
|
184
185
|
// pi reads the prompt from stdin and waits for EOF, so write then end.
|
|
@@ -29,6 +29,21 @@ export declare function git(cwd: string, args: string[], signal: AbortSignal | u
|
|
|
29
29
|
exitCode: number;
|
|
30
30
|
aborted: boolean;
|
|
31
31
|
}>;
|
|
32
|
+
/**
|
|
33
|
+
* Paths with unmerged index entries (an in-progress merge conflict), deduped.
|
|
34
|
+
* Empty outside a git repo or on any git error — this is a GUARD input, and a
|
|
35
|
+
* guard that cannot conclude must not block.
|
|
36
|
+
*
|
|
37
|
+
* Why it exists (mx5 run 6): a stale `git stash pop` mid-task left two paths UU;
|
|
38
|
+
* every later commit was doomed, three verify passes ran against a conflicted
|
|
39
|
+
* tree, and — worse — a blind `git add -A` on that index would have silently
|
|
40
|
+
* "resolved" the conflict with whatever happened to be on disk.
|
|
41
|
+
*/
|
|
42
|
+
export declare function gitUnmergedPaths(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string[]>;
|
|
43
|
+
/** Sha of `refs/stash`, or null when there is no stash (or not a git repo). Used
|
|
44
|
+
* to detect a stash created (or consumed) during a task and left behind — the
|
|
45
|
+
* exact landmine that detonated mx5 run 6 two days after it was pushed. */
|
|
46
|
+
export declare function gitStashRef(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string | null>;
|
|
32
47
|
/**
|
|
33
48
|
* Stage everything (`git add -A`) and commit it with `message`. Honors
|
|
34
49
|
* .gitignore via git itself. Never throws — failures surface as
|
package/dist/task/auto-commit.js
CHANGED
|
@@ -31,6 +31,39 @@ export async function git(cwd, args, signal, spawnFn) {
|
|
|
31
31
|
const r = await runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
32
32
|
return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, aborted: r.aborted };
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Paths with unmerged index entries (an in-progress merge conflict), deduped.
|
|
36
|
+
* Empty outside a git repo or on any git error — this is a GUARD input, and a
|
|
37
|
+
* guard that cannot conclude must not block.
|
|
38
|
+
*
|
|
39
|
+
* Why it exists (mx5 run 6): a stale `git stash pop` mid-task left two paths UU;
|
|
40
|
+
* every later commit was doomed, three verify passes ran against a conflicted
|
|
41
|
+
* tree, and — worse — a blind `git add -A` on that index would have silently
|
|
42
|
+
* "resolved" the conflict with whatever happened to be on disk.
|
|
43
|
+
*/
|
|
44
|
+
export async function gitUnmergedPaths(cwd, signal, spawnFn) {
|
|
45
|
+
const r = await git(cwd, ['ls-files', '-u'], signal, spawnFn);
|
|
46
|
+
if (r.exitCode !== 0)
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const line of r.stdout.split('\n')) {
|
|
50
|
+
// `<mode> <sha> <stage>\t<path>`
|
|
51
|
+
const tab = line.indexOf('\t');
|
|
52
|
+
if (tab === -1)
|
|
53
|
+
continue;
|
|
54
|
+
const p = line.slice(tab + 1).trim();
|
|
55
|
+
if (p.length > 0 && !out.includes(p))
|
|
56
|
+
out.push(p);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Sha of `refs/stash`, or null when there is no stash (or not a git repo). Used
|
|
61
|
+
* to detect a stash created (or consumed) during a task and left behind — the
|
|
62
|
+
* exact landmine that detonated mx5 run 6 two days after it was pushed. */
|
|
63
|
+
export async function gitStashRef(cwd, signal, spawnFn) {
|
|
64
|
+
const r = await git(cwd, ['rev-parse', '-q', '--verify', 'refs/stash'], signal, spawnFn);
|
|
65
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
66
|
+
}
|
|
34
67
|
/**
|
|
35
68
|
* Stage everything (`git add -A`) and commit it with `message`. Honors
|
|
36
69
|
* .gitignore via git itself. Never throws — failures surface as
|
|
@@ -44,21 +77,32 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
|
|
|
44
77
|
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
|
|
45
78
|
return { committed: false, reason: 'not a git repository' };
|
|
46
79
|
}
|
|
47
|
-
// 2.
|
|
80
|
+
// 2. REFUSE on an unmerged index: `git add -A` would silently "resolve" the
|
|
81
|
+
// conflict by staging whatever is on disk, and the commit that followed
|
|
82
|
+
// would bless a half-merged tree. Surface it instead — this state needs a
|
|
83
|
+
// human (or the loop's own loud stop), not a snapshot.
|
|
84
|
+
const unmerged = await gitUnmergedPaths(cwd, signal, spawnFn);
|
|
85
|
+
if (unmerged.length > 0) {
|
|
86
|
+
return {
|
|
87
|
+
committed: false,
|
|
88
|
+
reason: `git commit blocked: unresolved merge conflict (${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''})`
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// 3. Stage all working-tree changes (new, modified, deleted).
|
|
48
92
|
const add = await git(cwd, ['add', '-A'], signal, spawnFn);
|
|
49
93
|
if (add.aborted)
|
|
50
94
|
return { committed: false, reason: 'cancelled' };
|
|
51
95
|
if (add.exitCode !== 0) {
|
|
52
96
|
return { committed: false, reason: `git add failed: ${firstLine(add.stderr)}` };
|
|
53
97
|
}
|
|
54
|
-
//
|
|
98
|
+
// 4. Anything staged? `git diff --cached --quiet` exits 0 when the index
|
|
55
99
|
// matches HEAD (nothing to commit), 1 when there are staged changes.
|
|
56
100
|
const diff = await git(cwd, ['diff', '--cached', '--quiet'], signal, spawnFn);
|
|
57
101
|
if (diff.aborted)
|
|
58
102
|
return { committed: false, reason: 'cancelled' };
|
|
59
103
|
if (diff.exitCode === 0)
|
|
60
104
|
return { committed: false, reason: 'nothing to commit' };
|
|
61
|
-
//
|
|
105
|
+
// 5. Commit. A failure here is usually missing user.name/user.email config —
|
|
62
106
|
// retry once with a self-supplied identity rather than losing the snapshot
|
|
63
107
|
// (and with it enforce + every differential guard) for the whole run.
|
|
64
108
|
const commit = await git(cwd, ['commit', '-m', message], signal, spawnFn);
|
|
@@ -8,6 +8,30 @@ import { type GateDeps } from './task-gates.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export interface AutoDeps extends GateDeps {
|
|
10
10
|
runChild: (name: string, tools: string, prompt: string) => Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Paths with unmerged index entries (an in-progress merge conflict). The loop
|
|
13
|
+
* refuses to START a task on a conflicted tree — mx5 run 6 ran a full impl turn
|
|
14
|
+
* plus three verifies against one, with every commit doomed from the outset.
|
|
15
|
+
* Absent (tests) → treated as clean.
|
|
16
|
+
*/
|
|
17
|
+
unmergedPaths?: (cwd: string) => Promise<string[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Sha of refs/stash or null. Compared around each task so a stash pushed (or
|
|
20
|
+
* consumed) during the task and left behind is called out — an orphan stash is
|
|
21
|
+
* exactly the landmine that detonated as an unresolvable conflict in run 6.
|
|
22
|
+
* Absent (tests) → the check is skipped.
|
|
23
|
+
*/
|
|
24
|
+
stashRef?: (cwd: string) => Promise<string | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Whole-repo FINAL integration gate, run once when every task is checked off
|
|
27
|
+
* and BEFORE the run is declared complete (see final-gate.ts): the project's
|
|
28
|
+
* own static checks plus its own test/build commands, unaided. Absent (tests /
|
|
29
|
+
* gate off) → the run completes as before.
|
|
30
|
+
*/
|
|
31
|
+
finalGate?: (cwd: string) => Promise<{
|
|
32
|
+
ok: boolean;
|
|
33
|
+
reason: string;
|
|
34
|
+
}>;
|
|
11
35
|
}
|
|
12
36
|
/**
|
|
13
37
|
* Expand any @file references in the feature text by appending each referenced
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fsp from 'node:fs/promises';
|
|
9
9
|
import * as path from 'node:path';
|
|
10
|
-
import { gateRunTask } from './orchestrator.js';
|
|
10
|
+
import { gateRunTask, markResumable } from './orchestrator.js';
|
|
11
11
|
import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
|
|
12
12
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
13
13
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
|
|
@@ -25,6 +25,9 @@ import { startAutoLoader } from './widget.js';
|
|
|
25
25
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
26
26
|
import { buildGateDeps } from './gate-deps.js';
|
|
27
27
|
import { runGatesForTask } from './task-gates.js';
|
|
28
|
+
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
29
|
+
import { runFinalIntegrationGate } from './final-gate.js';
|
|
30
|
+
import { getConfig } from '../config/config.js';
|
|
28
31
|
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
29
32
|
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
30
33
|
// barrage the user — the real mx5 run asked 10, several of them redundant.
|
|
@@ -537,7 +540,15 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
537
540
|
stopLoader();
|
|
538
541
|
}
|
|
539
542
|
},
|
|
540
|
-
...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask })
|
|
543
|
+
...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask }),
|
|
544
|
+
// Loop-level repo integrity + run-end gate glue (see AutoDeps docs).
|
|
545
|
+
unmergedPaths: cwd2 => gitUnmergedPaths(cwd2, signal),
|
|
546
|
+
stashRef: cwd2 => gitStashRef(cwd2, signal),
|
|
547
|
+
// The final integration gate follows the `verify work` switch: it is the
|
|
548
|
+
// run-level half of the same verification story.
|
|
549
|
+
finalGate: cwd2 => getConfig().verifyWork ?
|
|
550
|
+
Promise.resolve(runFinalIntegrationGate(cwd2))
|
|
551
|
+
: Promise.resolve({ ok: true, reason: 'disabled' })
|
|
541
552
|
};
|
|
542
553
|
}
|
|
543
554
|
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
@@ -578,10 +589,58 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
578
589
|
const entries = parseTaskList(body);
|
|
579
590
|
const next = entries.find(e => !e.done);
|
|
580
591
|
if (!next) {
|
|
592
|
+
// FINAL INTEGRATION GATE: every task passed its own per-slice gates,
|
|
593
|
+
// but per-slice green has shipped a dead app twice (mx5 runs 3 & 5:
|
|
594
|
+
// statics clean, every protected route 500ing). Run the project's
|
|
595
|
+
// OWN whole-repo commands once, unaided, before declaring the run
|
|
596
|
+
// complete. On a FAIL the user decides: accept (complete anyway) or
|
|
597
|
+
// leave the run failed — a resume re-enters this branch and re-runs
|
|
598
|
+
// the gate, so fixing and resuming converges.
|
|
599
|
+
if (deps.finalGate) {
|
|
600
|
+
active.ui.notify(`${id}: running final integration gate…`, 'info');
|
|
601
|
+
const fin = await deps.finalGate(cwd);
|
|
602
|
+
if (!fin.ok) {
|
|
603
|
+
const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}\n\n`
|
|
604
|
+
+ 'All tasks are checked off — this is the whole-repo check '
|
|
605
|
+
+ '(the project’s own test/build/static commands, run unaided).';
|
|
606
|
+
const answer = await new SessionUI(active).ask({
|
|
607
|
+
localTitle: 'Final integration gate failed — how should pi proceed?',
|
|
608
|
+
displayQuestion: question,
|
|
609
|
+
question,
|
|
610
|
+
recommended: 'Leave failed — I will fix and /task-auto-resume',
|
|
611
|
+
recommended2: 'Accept — complete the run anyway',
|
|
612
|
+
allowSkip: false,
|
|
613
|
+
options: [
|
|
614
|
+
{
|
|
615
|
+
label: 'Leave failed — I will fix and /task-auto-resume',
|
|
616
|
+
value: 'fail'
|
|
617
|
+
},
|
|
618
|
+
{ label: 'Accept — complete the run anyway', value: 'accept' }
|
|
619
|
+
]
|
|
620
|
+
});
|
|
621
|
+
if (answer === undefined || !/^accept\b/i.test(answer.trim())) {
|
|
622
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
623
|
+
announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
581
629
|
await updateTaskFrontMatter(cwd, id, { state: 'completed' });
|
|
582
630
|
announceDone(active, `${id} complete — all ${entries.length} tasks done.`, 'info');
|
|
583
631
|
return;
|
|
584
632
|
}
|
|
633
|
+
// REFUSE to start on a conflicted tree: an unmerged index dooms every
|
|
634
|
+
// commit ahead and a `git add -A` would silently mis-resolve it. mx5
|
|
635
|
+
// run 6 burned a full impl turn + three verify passes exactly here.
|
|
636
|
+
const unmerged = deps.unmergedPaths ? await deps.unmergedPaths(cwd) : [];
|
|
637
|
+
if (unmerged.length > 0) {
|
|
638
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
639
|
+
announceDone(active, `${id} stopped before "${next.title}" — the repository has unresolved merge conflicts `
|
|
640
|
+
+ `(${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''}). `
|
|
641
|
+
+ 'Resolve them (git status), then /task-auto-resume.', 'error');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
585
644
|
active.ui.notify(`${id}: task ${next.index + 1}/${entries.length} — ${next.title}`, 'info');
|
|
586
645
|
// If this entry already has a stamped inner id, it was started in a
|
|
587
646
|
// previous (interrupted) run — resume it from its saved phase rather
|
|
@@ -612,6 +671,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
612
671
|
if (checkpoint.committed) {
|
|
613
672
|
active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
|
|
614
673
|
}
|
|
674
|
+
// Stash ref before the task: compared after the gates so a stash pushed
|
|
675
|
+
// during the task (impl model or any child) and left behind is called
|
|
676
|
+
// out instead of silently waiting to detonate in a later task.
|
|
677
|
+
const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
|
|
615
678
|
const res = await deps.runTask(active, cwd, next.title, {
|
|
616
679
|
resumeId,
|
|
617
680
|
// Fence this step against re-expanding the whole referenced spec:
|
|
@@ -634,10 +697,15 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
634
697
|
// this task's spec to finish it. (A plain ESC that the user
|
|
635
698
|
// follows with steering text never reaches here — that loops on
|
|
636
699
|
// the same task inside runSingleTask until a turn completes.)
|
|
700
|
+
await markResumable(cwd, res.taskId);
|
|
637
701
|
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
638
702
|
return;
|
|
639
703
|
}
|
|
640
704
|
if (!res.ok) {
|
|
705
|
+
// Demote the INNER task file too: it reads `completed` from
|
|
706
|
+
// spec-handoff, and leaving it that way is how a failed run's task
|
|
707
|
+
// file claimed success in the run 6 audit.
|
|
708
|
+
await markResumable(cwd, res.taskId);
|
|
641
709
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
642
710
|
// res.reason is set when the implementation turn itself died
|
|
643
711
|
// (e.g. a context-overflow 400) — surface it so the real cause
|
|
@@ -668,6 +736,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
668
736
|
});
|
|
669
737
|
active = gate.ctx;
|
|
670
738
|
if (gate.kind === 'paused') {
|
|
739
|
+
await markResumable(cwd, res.taskId);
|
|
671
740
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
672
741
|
announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
|
|
673
742
|
return;
|
|
@@ -677,16 +746,26 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
677
746
|
return;
|
|
678
747
|
}
|
|
679
748
|
if (gate.kind === 'interrupted') {
|
|
749
|
+
await markResumable(cwd, res.taskId);
|
|
680
750
|
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
681
751
|
return;
|
|
682
752
|
}
|
|
683
753
|
if (gate.kind === 'failed') {
|
|
754
|
+
await markResumable(cwd, res.taskId);
|
|
684
755
|
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
685
756
|
const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
|
|
686
757
|
announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
|
|
687
758
|
return;
|
|
688
759
|
}
|
|
689
|
-
// gate.kind === 'done' → fall through to the next task
|
|
760
|
+
// gate.kind === 'done' → fall through to the next task, after checking
|
|
761
|
+
// no landmine stash was left behind by anything that ran in between.
|
|
762
|
+
if (deps.stashRef && stashBefore !== undefined) {
|
|
763
|
+
const stashAfter = await deps.stashRef(cwd);
|
|
764
|
+
if (stashAfter !== stashBefore) {
|
|
765
|
+
active.ui.notify(`${id}: the git stash stack changed during "${next.title}" and was left that way — `
|
|
766
|
+
+ 'inspect `git stash list`; an orphan stash later pops as an unresolvable conflict.', 'warning');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
690
769
|
}
|
|
691
770
|
}
|
|
692
771
|
catch (err) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type HealthCommand } from './repo-health-check.js';
|
|
2
|
+
export interface FinalGateOutcome {
|
|
3
|
+
/** true → statics and every runnable integration command passed (or nothing to run). */
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** On a fail: the exact command, its exit code, and the tail of its output. */
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The project's OWN whole-repo integration commands (test, then build — test
|
|
10
|
+
* first because it is the richer signal and the more common script). First
|
|
11
|
+
* manifest that exists wins, mirroring discoverHealthCommands. Empty means
|
|
12
|
+
* "nothing to run" — the static half may still gate.
|
|
13
|
+
*/
|
|
14
|
+
export declare function discoverIntegrationCommands(cwd: string): {
|
|
15
|
+
ecosystem: string | null;
|
|
16
|
+
cmds: HealthCommand[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Run the final gate: static analysis first, then the discovered integration
|
|
20
|
+
* commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
|
|
21
|
+
* the hood (callers wrap in Promise.resolve). First real failure wins.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number): FinalGateOutcome;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-gate — the run-level integration gate /task-auto runs ONCE, after every
|
|
3
|
+
* task is checked off and before the run is declared complete.
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5 runs 3 and 5, validated): every existing gate is
|
|
6
|
+
* per-task/per-slice, so a run can finish with each slice individually blessed
|
|
7
|
+
* while the ASSEMBLED project is dead — statics green yet every protected route
|
|
8
|
+
* 500ing, 105/174 tests failing across slices, later tasks breaking files earlier
|
|
9
|
+
* tasks verified. Per-task repo-health closes the static half; nothing ever ran
|
|
10
|
+
* the project's own test/build commands against the finished whole.
|
|
11
|
+
*
|
|
12
|
+
* Like repo-health-check (whose discovery style this mirrors), it is deterministic
|
|
13
|
+
* — no model, no per-file narrowing, no "not my task" gray area. It discovers the
|
|
14
|
+
* project's OWN integration commands (the ones a fresh checkout / CI would run)
|
|
15
|
+
* and lets their REAL exit codes decide:
|
|
16
|
+
*
|
|
17
|
+
* - static analysis first (runRepoHealthCheck — cheap, precise), then
|
|
18
|
+
* - the project's own `test` and `build` commands, run verbatim and unaided.
|
|
19
|
+
*
|
|
20
|
+
* Environment-gap safety, same contract as repo-health-check: a command that
|
|
21
|
+
* CANNOT run (ENOENT, exit 127 = command-not-found inside the script chain, or a
|
|
22
|
+
* timeout) is an environment problem, not a code fault — it is SKIPPED, never
|
|
23
|
+
* failed. Only a command that actually ran and exited non-zero fails the gate,
|
|
24
|
+
* and the reason carries the tail of its real output so the user (and a resume
|
|
25
|
+
* fix) can act on it. A test suite that needs a database will fail here when the
|
|
26
|
+
* database is genuinely reachable-but-mis-wired — which is exactly the class the
|
|
27
|
+
* per-task gates kept excusing — and the caller puts a human on the decision
|
|
28
|
+
* (accept / leave failed), so a genuine external gap can still be overridden.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
34
|
+
function packageScripts(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
37
|
+
return j.scripts ?? {};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function makeHasTarget(cwd, target) {
|
|
44
|
+
try {
|
|
45
|
+
const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
|
|
46
|
+
return new RegExp(`^${target}:`, 'm').test(mk);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The project's OWN whole-repo integration commands (test, then build — test
|
|
54
|
+
* first because it is the richer signal and the more common script). First
|
|
55
|
+
* manifest that exists wins, mirroring discoverHealthCommands. Empty means
|
|
56
|
+
* "nothing to run" — the static half may still gate.
|
|
57
|
+
*/
|
|
58
|
+
export function discoverIntegrationCommands(cwd) {
|
|
59
|
+
if (existsSync(path.join(cwd, 'package.json'))) {
|
|
60
|
+
const s = packageScripts(cwd);
|
|
61
|
+
const cmds = [];
|
|
62
|
+
for (const name of ['test', 'build']) {
|
|
63
|
+
if (s[name])
|
|
64
|
+
cmds.push(['bun', ['run', name]]);
|
|
65
|
+
}
|
|
66
|
+
return { ecosystem: 'package.json', cmds };
|
|
67
|
+
}
|
|
68
|
+
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
69
|
+
const cmds = [];
|
|
70
|
+
for (const target of ['test', 'build']) {
|
|
71
|
+
if (makeHasTarget(cwd, target))
|
|
72
|
+
cmds.push(['make', [target]]);
|
|
73
|
+
}
|
|
74
|
+
return { ecosystem: 'Makefile', cmds };
|
|
75
|
+
}
|
|
76
|
+
if (existsSync(path.join(cwd, 'Cargo.toml'))) {
|
|
77
|
+
return {
|
|
78
|
+
ecosystem: 'Cargo.toml',
|
|
79
|
+
cmds: [
|
|
80
|
+
['cargo', ['test', '--quiet']],
|
|
81
|
+
['cargo', ['build', '--quiet']]
|
|
82
|
+
]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (existsSync(path.join(cwd, 'go.mod'))) {
|
|
86
|
+
return {
|
|
87
|
+
ecosystem: 'go.mod',
|
|
88
|
+
cmds: [
|
|
89
|
+
['go', ['test', './...']],
|
|
90
|
+
['go', ['build', './...']]
|
|
91
|
+
]
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (existsSync(path.join(cwd, 'pyproject.toml'))) {
|
|
95
|
+
return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
|
|
96
|
+
}
|
|
97
|
+
return { ecosystem: null, cmds: [] };
|
|
98
|
+
}
|
|
99
|
+
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
100
|
+
function outputTail(stdout, stderr, limit = 400) {
|
|
101
|
+
const combined = `${stdout}\n${stderr}`.trim();
|
|
102
|
+
if (combined.length === 0)
|
|
103
|
+
return '';
|
|
104
|
+
const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
|
|
105
|
+
return combined.length > limit ? `…${tail}` : tail;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run the final gate: static analysis first, then the discovered integration
|
|
109
|
+
* commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
|
|
110
|
+
* the hood (callers wrap in Promise.resolve). First real failure wins.
|
|
111
|
+
*/
|
|
112
|
+
export function runFinalIntegrationGate(cwd, timeoutMs = 900_000) {
|
|
113
|
+
const stat = runRepoHealthCheck(cwd);
|
|
114
|
+
if (!stat.ok)
|
|
115
|
+
return { ok: false, reason: `static checks: ${stat.reason}` };
|
|
116
|
+
const { ecosystem, cmds } = discoverIntegrationCommands(cwd);
|
|
117
|
+
if (!ecosystem || cmds.length === 0) {
|
|
118
|
+
return { ok: true, reason: 'no integration command found (statics passed)' };
|
|
119
|
+
}
|
|
120
|
+
const ran = [];
|
|
121
|
+
for (const [bin, args] of cmds) {
|
|
122
|
+
const label = `${bin} ${args.join(' ')}`;
|
|
123
|
+
const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
|
124
|
+
// Tool missing, timeout, or command-not-found inside the script chain →
|
|
125
|
+
// environment gap, not a code fault; skip (same contract as repo-health).
|
|
126
|
+
if (r.error || r.status === null || r.status === 127)
|
|
127
|
+
continue;
|
|
128
|
+
if (r.status !== 0) {
|
|
129
|
+
const tail = outputTail(r.stdout ?? '', r.stderr ?? '');
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: `\`${label}\` exited ${r.status}${tail ? ` — ${tail}` : ''}`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
ran.push(label);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
reason: ran.length > 0 ?
|
|
140
|
+
`statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
|
|
141
|
+
: 'statics passed (integration commands not runnable here)'
|
|
142
|
+
};
|
|
143
|
+
}
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -23,6 +23,7 @@ import { runRepoHealthCheck } from './repo-health-check.js';
|
|
|
23
23
|
import { researchResolution } from './verify-resolution.js';
|
|
24
24
|
import { findSubstitutionSuspects } from './substitution-probe.js';
|
|
25
25
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
26
|
+
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
26
27
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
27
28
|
import { formatLoopHint } from './child-runner.js';
|
|
28
29
|
import { getConfig } from '../config/config.js';
|
|
@@ -83,6 +84,10 @@ export function buildGateDeps(params) {
|
|
|
83
84
|
// output line and context usage, exactly like the single-task phase widget.
|
|
84
85
|
let lastLine;
|
|
85
86
|
let contextUsage;
|
|
87
|
+
// What the git-state guard had to restore after the MOST RECENT verify/recommend
|
|
88
|
+
// child run (see git-state-guard.ts). runWorkVerification reads this through its
|
|
89
|
+
// mutationCheck dep to discard a verdict computed on a mutated tree.
|
|
90
|
+
let lastGuardReconcile = null;
|
|
86
91
|
// Shared runner for the per-task GATE children (verify + post-FAIL recommend).
|
|
87
92
|
// Both are read-only passes of the same local model that must run to completion:
|
|
88
93
|
// unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
|
|
@@ -92,12 +97,20 @@ export function buildGateDeps(params) {
|
|
|
92
97
|
const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
|
|
93
98
|
lastLine = undefined;
|
|
94
99
|
contextUsage = undefined;
|
|
100
|
+
lastGuardReconcile = null;
|
|
95
101
|
const startedAt = Date.now();
|
|
96
102
|
const logPath = path.join(tasksDir(cwd2), logFile);
|
|
97
103
|
const log = (msg) => {
|
|
98
104
|
void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
|
|
99
105
|
};
|
|
100
106
|
log(`=== ${kind} start: ${taskTitle} ===`);
|
|
107
|
+
// GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
|
|
108
|
+
// contract is prompt-level and the live model breaks it (mx5 run 6: the
|
|
109
|
+
// verify child `git stash`ed the task's uncommitted work and never popped
|
|
110
|
+
// — the impl was destroyed and the orphan stash detonated 2 days later).
|
|
111
|
+
// Snapshot before, deterministically restore after; lint-fix is excluded
|
|
112
|
+
// because editing is its job (it carries its own revert guard).
|
|
113
|
+
const guardSnapshot = kind === 'verify' || kind === 'recommend' ? await captureGitState(cwd2, sig) : null;
|
|
101
114
|
const stopLoader = startAutoLoader(gateCtx, () => ({
|
|
102
115
|
title: taskTitle,
|
|
103
116
|
kind,
|
|
@@ -109,21 +122,36 @@ export function buildGateDeps(params) {
|
|
|
109
122
|
contextUsage
|
|
110
123
|
}));
|
|
111
124
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
let r;
|
|
126
|
+
try {
|
|
127
|
+
r = await runWorker({
|
|
128
|
+
prompt,
|
|
129
|
+
cwd: cwd2,
|
|
130
|
+
signal: sig,
|
|
131
|
+
tools,
|
|
132
|
+
timeoutMs: 0,
|
|
133
|
+
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
134
|
+
onLine: line => {
|
|
135
|
+
lastLine = line;
|
|
136
|
+
log(line);
|
|
137
|
+
},
|
|
138
|
+
onContextUsage: snapshot => {
|
|
139
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
// Restore whatever the child moved BEFORE any verdict/failure is
|
|
145
|
+
// acted on — a crashed child must not skip the restore either.
|
|
146
|
+
if (guardSnapshot) {
|
|
147
|
+
const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
|
|
148
|
+
lastGuardReconcile = rec;
|
|
149
|
+
if (rec.mutated) {
|
|
150
|
+
log(`=== ${kind} GIT-STATE GUARD — child mutated repo state; restored: ${rec.actions.join('; ')} ===`);
|
|
151
|
+
gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
|
|
152
|
+
}
|
|
125
153
|
}
|
|
126
|
-
}
|
|
154
|
+
}
|
|
127
155
|
if (r.loopHit) {
|
|
128
156
|
log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
129
157
|
gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
@@ -251,7 +279,13 @@ export function buildGateDeps(params) {
|
|
|
251
279
|
// Deterministic self-verification probe: test files the task itself
|
|
252
280
|
// authored/changed become prompt-level findings mandating the child
|
|
253
281
|
// to drive the real artifact before trusting their green result.
|
|
254
|
-
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects)
|
|
282
|
+
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
|
|
283
|
+
// Git-state guard result of the most recent child run: a verdict
|
|
284
|
+
// computed on a tree the child itself mutated is discarded (the
|
|
285
|
+
// guard already restored the state — see git-state-guard.ts).
|
|
286
|
+
mutationCheck: () => lastGuardReconcile?.mutated ?
|
|
287
|
+
{ mutated: true, detail: lastGuardReconcile.actions.join('; ') }
|
|
288
|
+
: { mutated: false, detail: '' }
|
|
255
289
|
});
|
|
256
290
|
},
|
|
257
291
|
lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type SpawnFn } from '../shared/child-process.js';
|
|
2
|
+
export interface GitStateSnapshot {
|
|
3
|
+
/** false → not a usable git worktree; the guard is disabled for this run. */
|
|
4
|
+
ok: boolean;
|
|
5
|
+
headSha: string;
|
|
6
|
+
/** Symbolic ref ("refs/heads/main") when HEAD is on a branch, else null (detached). */
|
|
7
|
+
branchRef: string | null;
|
|
8
|
+
/** Sha of refs/stash, or null when there is no stash. */
|
|
9
|
+
stashSha: string | null;
|
|
10
|
+
/** Tree object capturing the worktree content (tracked + untracked, minus .pi-tasks). */
|
|
11
|
+
treeSha: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ReconcileResult {
|
|
14
|
+
/** true → the child moved repo state; every detected move was restored. */
|
|
15
|
+
mutated: boolean;
|
|
16
|
+
/** Human-readable restore actions, for the debug log / notify / gate trail. */
|
|
17
|
+
actions: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Capture the repo state a gate child must leave untouched. */
|
|
20
|
+
export declare function captureGitState(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<GitStateSnapshot>;
|
|
21
|
+
/**
|
|
22
|
+
* Compare the repo state against a pre-run snapshot and deterministically undo
|
|
23
|
+
* whatever a gate child moved. Ordering matters:
|
|
24
|
+
*
|
|
25
|
+
* 1. HEAD first — a child parked on another commit must be back on the original
|
|
26
|
+
* ref before the worktree comparison/restore makes sense.
|
|
27
|
+
* 2. Worktree content from the snapshot TREE (not from any stash the child may
|
|
28
|
+
* have pushed — the snapshot is the authoritative "as the child found it").
|
|
29
|
+
* 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
|
|
30
|
+
* is already restored — this is exactly the orphan that detonated mx5 run 6.
|
|
31
|
+
*
|
|
32
|
+
* Never throws; failures degrade to actions[] lines so the caller can log them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reconcileGitState(cwd: string, before: GitStateSnapshot, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<ReconcileResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Convenience wrapper for gate call-sites: run `fn` between a capture and a
|
|
37
|
+
* reconcile, and hand back both the result and what (if anything) had to be
|
|
38
|
+
* restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
|
|
39
|
+
* not skip the restore.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withGitStateGuard<T>(cwd: string, fn: () => Promise<T>, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<{
|
|
42
|
+
result: T;
|
|
43
|
+
reconcile: ReconcileResult;
|
|
44
|
+
}>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-state-guard — deterministic repo-state snapshot/reconcile around the
|
|
3
|
+
* read-only gate children (verify, recommend).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (proven twice on mx5): those children hold a `read,bash`
|
|
6
|
+
* contract whose "never modify the tree" clause is prompt-level only, and the live
|
|
7
|
+
* local model breaks it. Run 6's verify child ran `git stash; git checkout HEAD~1;
|
|
8
|
+
* tsc; git checkout HEAD` with NO pop — the task's whole uncommitted implementation
|
|
9
|
+
* vanished into a stash, the verify judged an empty tree (a full re-implementation
|
|
10
|
+
* was burned), and the orphaned stash detonated two days later when a later impl
|
|
11
|
+
* turn popped it onto a 14-commits-newer HEAD (unresolvable UU conflict, the
|
|
12
|
+
* /task-auto checklist reverted to a stale state). The same child has been observed
|
|
13
|
+
* running `eslint --fix .` mid-verification and ad-hoc DDL against the test DB.
|
|
14
|
+
*
|
|
15
|
+
* Prompt rules are evidence-insufficient for this class (FROZEN-CONTRACT framing
|
|
16
|
+
* A/B'd 0–1/5 compliance), so this is a capability-shaped fix: snapshot the repo
|
|
17
|
+
* state BEFORE the child runs, and afterwards deterministically restore anything
|
|
18
|
+
* it moved — no model in the loop.
|
|
19
|
+
*
|
|
20
|
+
* What is captured / reconciled:
|
|
21
|
+
* - HEAD (sha + symbolic branch ref): a child that checked out another commit
|
|
22
|
+
* and never came back is checked back out.
|
|
23
|
+
* - The WORKTREE CONTENT as a git tree object, built through a temporary index
|
|
24
|
+
* (`read-tree --empty` + `add -A` + `write-tree`, excluding .pi-tasks — the
|
|
25
|
+
* gate's own debug logs land there DURING the run). This snapshots tracked
|
|
26
|
+
* *and* untracked (non-ignored) files without touching the real index or the
|
|
27
|
+
* stash. Restoration re-materialises every changed/deleted file from the
|
|
28
|
+
* snapshot tree and deletes files the child created.
|
|
29
|
+
* - The STASH ref: entries the child pushed are dropped AFTER the worktree is
|
|
30
|
+
* restored from the snapshot (the snapshot, not the stash, is the source of
|
|
31
|
+
* truth), so no landmine stash survives the reconcile.
|
|
32
|
+
*
|
|
33
|
+
* The real index's staging state is deliberately NOT restored: pre-commit gate
|
|
34
|
+
* children run against a tree whose work is unstaged, and the auto-commit that
|
|
35
|
+
* follows re-stages everything with `add -A` anyway.
|
|
36
|
+
*
|
|
37
|
+
* Everything is best-effort: a repo where git itself fails (not a work tree, git
|
|
38
|
+
* missing) disables the guard (capture returns ok:false and reconcile no-ops) —
|
|
39
|
+
* the gate must keep working in non-git projects exactly as before.
|
|
40
|
+
*/
|
|
41
|
+
import * as fsp from 'node:fs/promises';
|
|
42
|
+
import * as os from 'node:os';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
import { runChildDefault } from '../shared/child-process.js';
|
|
45
|
+
/** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
|
|
46
|
+
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
47
|
+
function makeGit(cwd, signal, spawnFn) {
|
|
48
|
+
return async (args, env) => {
|
|
49
|
+
const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
50
|
+
return { stdout: r.stdout, exitCode: r.exitCode };
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Snapshot the worktree content into a tree object via a THROWAWAY index file, so
|
|
55
|
+
* neither the real index nor the stash is touched. Returns null when git cannot
|
|
56
|
+
* build the tree (odd states — the guard then skips tree reconciliation).
|
|
57
|
+
*/
|
|
58
|
+
async function captureWorktreeTree(git) {
|
|
59
|
+
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
60
|
+
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
61
|
+
try {
|
|
62
|
+
const empty = await git(['read-tree', '--empty'], env);
|
|
63
|
+
if (empty.exitCode !== 0)
|
|
64
|
+
return null;
|
|
65
|
+
const add = await git(['add', '-A', '--', '.', EXCLUDE_TASKS_DIR], env);
|
|
66
|
+
if (add.exitCode !== 0)
|
|
67
|
+
return null;
|
|
68
|
+
const tree = await git(['write-tree'], env);
|
|
69
|
+
return tree.exitCode === 0 ? tree.stdout.trim() : null;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fsp.rm(tmpIndex, { force: true }).catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Capture the repo state a gate child must leave untouched. */
|
|
76
|
+
export async function captureGitState(cwd, signal, spawnFn) {
|
|
77
|
+
const git = makeGit(cwd, signal, spawnFn);
|
|
78
|
+
const disabled = {
|
|
79
|
+
ok: false,
|
|
80
|
+
headSha: '',
|
|
81
|
+
branchRef: null,
|
|
82
|
+
stashSha: null,
|
|
83
|
+
treeSha: null
|
|
84
|
+
};
|
|
85
|
+
const inside = await git(['rev-parse', '--is-inside-work-tree']);
|
|
86
|
+
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true')
|
|
87
|
+
return disabled;
|
|
88
|
+
const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
|
|
89
|
+
// An unborn HEAD (fresh init, no commits) has nothing to restore to — disable.
|
|
90
|
+
if (head.exitCode !== 0)
|
|
91
|
+
return disabled;
|
|
92
|
+
const branch = await git(['symbolic-ref', '-q', 'HEAD']);
|
|
93
|
+
const stash = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
headSha: head.stdout.trim(),
|
|
97
|
+
branchRef: branch.exitCode === 0 ? branch.stdout.trim() : null,
|
|
98
|
+
stashSha: stash.exitCode === 0 ? stash.stdout.trim() : null,
|
|
99
|
+
treeSha: await captureWorktreeTree(git)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Restore every file recorded in `beforeTree` (content + deletions) and remove
|
|
104
|
+
* files that exist in `afterTree` but not in `beforeTree` (files the child
|
|
105
|
+
* created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
|
|
106
|
+
* -a -f` re-materialises the snapshot verbatim.
|
|
107
|
+
*/
|
|
108
|
+
async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
|
|
109
|
+
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
110
|
+
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
111
|
+
try {
|
|
112
|
+
// Files present only in afterTree were created by the child — delete them
|
|
113
|
+
// BEFORE checkout so a restore failure cannot leave both halves stale.
|
|
114
|
+
const added = await git([
|
|
115
|
+
'diff-tree',
|
|
116
|
+
'-r',
|
|
117
|
+
'--diff-filter=A',
|
|
118
|
+
'--name-only',
|
|
119
|
+
beforeTree,
|
|
120
|
+
afterTree
|
|
121
|
+
]);
|
|
122
|
+
if (added.exitCode === 0) {
|
|
123
|
+
for (const rel of added.stdout.split('\n')) {
|
|
124
|
+
const name = rel.trim();
|
|
125
|
+
if (name.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
|
|
128
|
+
actions.push(`removed child-created file ${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const read = await git(['read-tree', beforeTree], env);
|
|
132
|
+
if (read.exitCode !== 0) {
|
|
133
|
+
actions.push('worktree restore FAILED (read-tree)');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const co = await git(['checkout-index', '-a', '-f'], env);
|
|
137
|
+
actions.push(co.exitCode === 0 ?
|
|
138
|
+
'restored worktree files from pre-run snapshot'
|
|
139
|
+
: 'worktree restore FAILED (checkout-index)');
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await fsp.rm(tmpIndex, { force: true }).catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Compare the repo state against a pre-run snapshot and deterministically undo
|
|
147
|
+
* whatever a gate child moved. Ordering matters:
|
|
148
|
+
*
|
|
149
|
+
* 1. HEAD first — a child parked on another commit must be back on the original
|
|
150
|
+
* ref before the worktree comparison/restore makes sense.
|
|
151
|
+
* 2. Worktree content from the snapshot TREE (not from any stash the child may
|
|
152
|
+
* have pushed — the snapshot is the authoritative "as the child found it").
|
|
153
|
+
* 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
|
|
154
|
+
* is already restored — this is exactly the orphan that detonated mx5 run 6.
|
|
155
|
+
*
|
|
156
|
+
* Never throws; failures degrade to actions[] lines so the caller can log them.
|
|
157
|
+
*/
|
|
158
|
+
export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
159
|
+
if (!before.ok)
|
|
160
|
+
return { mutated: false, actions: [] };
|
|
161
|
+
const git = makeGit(cwd, signal, spawnFn);
|
|
162
|
+
const actions = [];
|
|
163
|
+
// 1. HEAD / branch.
|
|
164
|
+
const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
|
|
165
|
+
const branch = await git(['symbolic-ref', '-q', 'HEAD']);
|
|
166
|
+
const headSha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
167
|
+
const branchRef = branch.exitCode === 0 ? branch.stdout.trim() : null;
|
|
168
|
+
if (headSha !== before.headSha || branchRef !== before.branchRef) {
|
|
169
|
+
const target = before.branchRef ? before.branchRef.replace(/^refs\/heads\//, '') : before.headSha;
|
|
170
|
+
const co = await git(['checkout', '-f', target]);
|
|
171
|
+
actions.push(co.exitCode === 0 ?
|
|
172
|
+
`checked HEAD back out to ${target}`
|
|
173
|
+
: `HEAD restore FAILED (checkout ${target})`);
|
|
174
|
+
}
|
|
175
|
+
// 2. Worktree content.
|
|
176
|
+
if (before.treeSha) {
|
|
177
|
+
const afterTree = await captureWorktreeTree(git);
|
|
178
|
+
if (afterTree && afterTree !== before.treeSha) {
|
|
179
|
+
await restoreWorktree(cwd, git, before.treeSha, afterTree, actions);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
|
|
183
|
+
// snapshot again (bounded — a child pushes at most a handful; 10 is beyond
|
|
184
|
+
// anything observed). A stash the child POPPED (ref gone/behind) cannot be
|
|
185
|
+
// reconstructed — report it instead of guessing.
|
|
186
|
+
const stashNow = async () => {
|
|
187
|
+
const s = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
|
|
188
|
+
return s.exitCode === 0 ? s.stdout.trim() : null;
|
|
189
|
+
};
|
|
190
|
+
let stash = await stashNow();
|
|
191
|
+
if (stash !== before.stashSha) {
|
|
192
|
+
if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
|
|
193
|
+
let dropped = 0;
|
|
194
|
+
while (stash !== before.stashSha && stash !== null && dropped < 10) {
|
|
195
|
+
const drop = await git(['stash', 'drop', 'stash@{0}']);
|
|
196
|
+
if (drop.exitCode !== 0)
|
|
197
|
+
break;
|
|
198
|
+
dropped++;
|
|
199
|
+
stash = await stashNow();
|
|
200
|
+
}
|
|
201
|
+
actions.push(stash === before.stashSha ?
|
|
202
|
+
`dropped ${dropped} stash entr${dropped === 1 ? 'y' : 'ies'} the child pushed`
|
|
203
|
+
: 'stash restore INCOMPLETE (drop failed)');
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
actions.push('stash ref changed in a way that cannot be undone (entry popped/dropped)');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { mutated: actions.length > 0, actions };
|
|
210
|
+
}
|
|
211
|
+
/** Is `ancestorStash` still reachable in the stash reflog chain at `tipSha`? Used to
|
|
212
|
+
* tell "child pushed on top" (droppable) from "child popped/dropped ours" (not). */
|
|
213
|
+
async function stashContains(git, tipSha, wantedSha) {
|
|
214
|
+
if (tipSha === null)
|
|
215
|
+
return false;
|
|
216
|
+
const log = await git(['rev-list', '-g', 'refs/stash']);
|
|
217
|
+
if (log.exitCode !== 0)
|
|
218
|
+
return false;
|
|
219
|
+
return log.stdout
|
|
220
|
+
.split('\n')
|
|
221
|
+
.map(l => l.trim())
|
|
222
|
+
.includes(wantedSha);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Convenience wrapper for gate call-sites: run `fn` between a capture and a
|
|
226
|
+
* reconcile, and hand back both the result and what (if anything) had to be
|
|
227
|
+
* restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
|
|
228
|
+
* not skip the restore.
|
|
229
|
+
*/
|
|
230
|
+
export async function withGitStateGuard(cwd, fn, signal, spawnFn) {
|
|
231
|
+
const before = await captureGitState(cwd, signal, spawnFn);
|
|
232
|
+
try {
|
|
233
|
+
const result = await fn();
|
|
234
|
+
return { result, reconcile: await reconcileGitState(cwd, before, signal, spawnFn) };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
await reconcileGitState(cwd, before, signal, spawnFn).catch(() => { });
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -226,6 +226,14 @@ export declare function runSingleTask(ctx: ExtensionCommandContext, cwd: string,
|
|
|
226
226
|
* orchestrators, to keep the dependency graph acyclic).
|
|
227
227
|
*/
|
|
228
228
|
export declare const gateRunTask: RunTaskFn;
|
|
229
|
+
/**
|
|
230
|
+
* Demote a task file to a resumable state after a gate (or its implementation)
|
|
231
|
+
* stopped short. The file is marked `completed` at spec-handoff — before the work
|
|
232
|
+
* is even verified — so a gate FAIL would otherwise leave it un-resumable
|
|
233
|
+
* (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
|
|
234
|
+
* no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
|
|
235
|
+
*/
|
|
236
|
+
export declare function markResumable(cwd: string, taskId: string): Promise<void>;
|
|
229
237
|
/**
|
|
230
238
|
* Run a single /task through implementation AND the shared verify + enforce gates,
|
|
231
239
|
* blocking until both finish. Used instead of the fire-and-forget handoff whenever
|
|
@@ -581,7 +581,7 @@ export const gateRunTask = (c, cwd, t, opts) => runSingleTask(c, cwd, t, {
|
|
|
581
581
|
* (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
|
|
582
582
|
* no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
|
|
583
583
|
*/
|
|
584
|
-
async function markResumable(cwd, taskId) {
|
|
584
|
+
export async function markResumable(cwd, taskId) {
|
|
585
585
|
if (!taskId)
|
|
586
586
|
return;
|
|
587
587
|
try {
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -55,6 +55,11 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
|
|
|
55
55
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
|
56
56
|
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
57
57
|
}
|
|
58
|
+
/** Is live web search configured for this process? Mirrors search-core's env lookup. */
|
|
59
|
+
export declare function searchConfigured(getEnv?: (k: string) => string | undefined): boolean;
|
|
60
|
+
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
61
|
+
* (the validated shape for getting a local model to actually reach for search). */
|
|
62
|
+
export declare const RESEARCH_SEARCH_HINT: string;
|
|
58
63
|
/**
|
|
59
64
|
* The TOOLING worker only needs to know which verification commands the task
|
|
60
65
|
* cares about — never the per-file edit list. Big refined prompts embed a long
|
package/dist/task/phases.js
CHANGED
|
@@ -145,6 +145,24 @@ export async function phaseVerifyTooling(deps, research) {
|
|
|
145
145
|
return replaceToolingWithVerified(research, parsed.verified);
|
|
146
146
|
}
|
|
147
147
|
const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
|
|
148
|
+
/** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
|
|
149
|
+
* when a Brave key is configured (the tool without a key just errors, and a weak
|
|
150
|
+
* model burns calls on it). Search being absent from the research toolset was
|
|
151
|
+
* STRUCTURAL: three consecutive audited runs made 0 search calls because the
|
|
152
|
+
* child literally did not have the tool. */
|
|
153
|
+
const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
|
|
154
|
+
/** Is live web search configured for this process? Mirrors search-core's env lookup. */
|
|
155
|
+
export function searchConfigured(getEnv = k => process.env[k]) {
|
|
156
|
+
return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
|
|
157
|
+
}
|
|
158
|
+
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
159
|
+
* (the validated shape for getting a local model to actually reach for search). */
|
|
160
|
+
export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for external facts your training data may have stale: '
|
|
161
|
+
+ 'the CURRENT version of a framework/runtime the task pins, a breaking API change you are '
|
|
162
|
+
+ 'not sure shipped, an error message you cannot explain from the code. Call '
|
|
163
|
+
+ '`pi-worker-search(query)` first, then `pi-worker-fetch(url)` on the result you want to '
|
|
164
|
+
+ 'read. Do NOT answer version or release questions from memory. Skip search entirely for '
|
|
165
|
+
+ "anything the project's own files or pi-worker-docs already answer.";
|
|
148
166
|
/**
|
|
149
167
|
* In-process guards loaded into the TOOLING worker only: block a re-read of any
|
|
150
168
|
* file already read, and block any byte-identical grep/find/ls repeat, feeding
|
|
@@ -338,10 +356,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
338
356
|
{
|
|
339
357
|
section: 'APIS',
|
|
340
358
|
label: 'worker:apis',
|
|
341
|
-
// Read-heavy: gets the orientation core (see note above).
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
359
|
+
// Read-heavy: gets the orientation core (see note above). Search/fetch
|
|
360
|
+
// ride along only when a Brave key exists — see SEARCH_EXTENSION_PATH.
|
|
361
|
+
prompt: appendNoThink(orientation.block
|
|
362
|
+
+ promptHeader
|
|
363
|
+
+ RESEARCH_APIS_PROMPT(refined)
|
|
364
|
+
+ (searchConfigured() ? RESEARCH_SEARCH_HINT : '')),
|
|
365
|
+
tools: 'read,grep,find,ls,pi-worker-docs'
|
|
366
|
+
+ (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
|
|
367
|
+
extensions: [
|
|
368
|
+
DOCS_EXTENSION_PATH,
|
|
369
|
+
...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
|
|
370
|
+
]
|
|
345
371
|
},
|
|
346
372
|
{
|
|
347
373
|
section: 'CONTEXT',
|
package/dist/task/task-gates.js
CHANGED
|
@@ -127,10 +127,20 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
127
127
|
break;
|
|
128
128
|
}
|
|
129
129
|
// AUTOFIX: re-run the implementation turn with the failure (and any typed
|
|
130
|
-
// guidance) prepended as a RE-ATTEMPT banner, then re-verify.
|
|
130
|
+
// guidance) prepended as a RE-ATTEMPT banner, then re-verify. The
|
|
131
|
+
// recommendation child already LOCATED the defect while deciding (mx5
|
|
132
|
+
// run 6: it pinned the exact SQL alias bug and the afterAll DB-drop) —
|
|
133
|
+
// hand that diagnosis to the re-run so it fixes the located cause
|
|
134
|
+
// instead of re-deriving it from the bare FAIL line. Skipped when there
|
|
135
|
+
// is no researched rationale beyond the failure text itself.
|
|
131
136
|
await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
|
|
132
137
|
active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
|
|
133
|
-
const
|
|
138
|
+
const diagnosis = (recOutcome.recommend === 'autofix'
|
|
139
|
+
&& recOutcome.rationale.length > 0
|
|
140
|
+
&& recOutcome.rationale !== failReason) ?
|
|
141
|
+
`\n\nDIAGNOSIS (a read-only investigation of this failure found):\n${recOutcome.rationale}`
|
|
142
|
+
: '';
|
|
143
|
+
const fixInstruction = `${failReason}${diagnosis}${choice.guidance ? `\n\nUser guidance: ${choice.guidance}` : ''}`;
|
|
134
144
|
const fixRes = await deps.runTask(active, p.cwd, p.title, {
|
|
135
145
|
resumeId: p.taskId,
|
|
136
146
|
planContext: p.planContext,
|
|
@@ -167,8 +177,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
167
177
|
// A benign skip ("nothing to commit", auto-commit off) is a warning. A real
|
|
168
178
|
// git failure is louder: it silently disables enforce AND every commit-based
|
|
169
179
|
// guard — mx5 run 4 lost all 10 commits (no container git identity) with only
|
|
170
|
-
// per-task warnings to show for it.
|
|
171
|
-
|
|
180
|
+
// per-task warnings to show for it. "blocked" is the unmerged-index refusal
|
|
181
|
+
// (gitCommitAll) — the same severity: nothing can commit until it's resolved.
|
|
182
|
+
const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
|
|
172
183
|
active.ui.notify(gitFailure ?
|
|
173
184
|
`${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
|
|
174
185
|
: `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
|
|
@@ -82,6 +82,18 @@ export interface VerificationDeps {
|
|
|
82
82
|
* into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
|
|
83
83
|
* the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
|
|
84
84
|
probe?: () => Promise<string[]>;
|
|
85
|
+
/**
|
|
86
|
+
* Result of the git-state guard for the MOST RECENT runChild call (see
|
|
87
|
+
* git-state-guard.ts): did the child mutate repo state (stash/checkout/file
|
|
88
|
+
* rewrites), which the guard then restored? A verdict computed on a mutated
|
|
89
|
+
* tree is untrustworthy in BOTH directions — the mx5 run 6 child stashed the
|
|
90
|
+
* work away and judged an empty tree — so it is discarded: the first mutated
|
|
91
|
+
* run is retried once on the restored tree; a second mutation is a FAIL that
|
|
92
|
+
* names the behavior. ABSENT → no guard (tests / non-git repos), unchanged. */
|
|
93
|
+
mutationCheck?: () => {
|
|
94
|
+
mutated: boolean;
|
|
95
|
+
detail: string;
|
|
96
|
+
};
|
|
85
97
|
}
|
|
86
98
|
/**
|
|
87
99
|
* Run the verification pass for one task. A missing spec is a pass. Otherwise run
|
package/dist/task/verify-work.js
CHANGED
|
@@ -320,6 +320,20 @@ export async function runWorkVerification(deps) {
|
|
|
320
320
|
const msg = err instanceof Error ? err.message : String(err);
|
|
321
321
|
return { ok: false, reason: `verification pass could not run: ${msg}` };
|
|
322
322
|
}
|
|
323
|
+
// A child that mutated the repo (git-state guard fired) judged a tree it had
|
|
324
|
+
// itself changed — its verdict is meaningless in both directions, so discard
|
|
325
|
+
// it BEFORE parsing. The guard already restored the state, so one retry runs
|
|
326
|
+
// on the pristine tree; a child that mutates again is reported as the fault.
|
|
327
|
+
const mutation = deps.mutationCheck?.();
|
|
328
|
+
if (mutation?.mutated) {
|
|
329
|
+
if (attempt === 1)
|
|
330
|
+
continue;
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
reason: 'verify child mutated repo state and its verdict was discarded '
|
|
334
|
+
+ `(state restored: ${mutation.detail.slice(0, 200)})`
|
|
335
|
+
};
|
|
336
|
+
}
|
|
323
337
|
const verdict = parseVerifyVerdict(text);
|
|
324
338
|
if (verdict.pass)
|
|
325
339
|
return { ok: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.26",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|