@jhizzard/termdeck 1.5.0 → 1.6.0
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/package.json +2 -1
- package/packages/server/share/termdeck/templates/claude-code-auditor.txt +34 -0
- package/packages/server/share/termdeck/templates/claude-code-orchestrator.txt +30 -0
- package/packages/server/share/termdeck/templates/claude-code-worker.txt +26 -0
- package/packages/server/share/termdeck/templates/codex-auditor.txt +36 -0
- package/packages/server/share/termdeck/templates/codex-worker.txt +28 -0
- package/packages/server/share/termdeck/templates/gemini-auditor.txt +33 -0
- package/packages/server/share/termdeck/templates/gemini-worker.txt +25 -0
- package/packages/server/share/termdeck/templates/grok-auditor.txt +35 -0
- package/packages/server/share/termdeck/templates/grok-worker.txt +25 -0
- package/packages/server/src/index.js +19 -0
- package/packages/server/src/parked-detection.js +50 -0
- package/packages/server/src/session.js +4 -0
- package/packages/server/src/sprint-routes.js +17 -0
- package/packages/server/src/sprints/inject.js +323 -0
- package/packages/server/src/sprints/nudge.js +147 -0
- package/packages/server/src/sprints/status-parser.js +129 -0
- package/packages/server/src/templates/template-engine.js +197 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"packages/cli/src/**",
|
|
11
11
|
"packages/cli/templates/**",
|
|
12
12
|
"packages/server/src/**",
|
|
13
|
+
"packages/server/share/**",
|
|
13
14
|
"packages/client/public/**",
|
|
14
15
|
"packages/stack-installer/assets/hooks/**",
|
|
15
16
|
"config/config.example.yaml",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
You are {{lane_tag}} (auditor) in {{sprint_name}}, running in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence:
|
|
4
|
+
1. memory_recall(project="{{project_name}}", query="{{memory_query_lane}}")
|
|
5
|
+
2. memory_recall(query="{{memory_query_broad}}")
|
|
6
|
+
3. Read ~/.claude/CLAUDE.md and ./CLAUDE.md
|
|
7
|
+
4. Read {{sprint_dir}}/PLANNING.md
|
|
8
|
+
5. Read {{sprint_dir}}/STATUS.md
|
|
9
|
+
6. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative)
|
|
10
|
+
|
|
11
|
+
{{cross_lane_intel}}
|
|
12
|
+
|
|
13
|
+
Synchronize on LANDED (read this first). The build sprint produces code over time, not in one shot. Until ≥1 worker posts `### [T<n>] LANDED`, your AUDIT-REDs would be observations of the pre-build state, NOT failure findings. Do NOT issue FINAL-VERDICT until either: (a) the orchestrator closes the sprint explicitly, or (b) all worker lanes have posted DONE. While waiting, post `CHECKPOINT` (see mandate below) — do not idle silently.
|
|
14
|
+
|
|
15
|
+
Audit cycle per worker LANDED:
|
|
16
|
+
1. Read the LANDED post + the file:line evidence it cites.
|
|
17
|
+
2. Independently reproduce — do NOT borrow the worker's test fixture. Build a minimal payload from scratch.
|
|
18
|
+
3. On broken: `### [{{lane_tag}}] AUDIT-RED 2026-MM-DD HH:MM ET — <file:line + repro + reasoning>` routed to the owning worker lane.
|
|
19
|
+
4. On sound: record verified-pass for that LANDED inside the next CHECKPOINT post (no separate "GREEN" per item — only the final FINAL-VERDICT).
|
|
20
|
+
|
|
21
|
+
CHECKPOINT mandate (load-bearing). Post `### [{{lane_tag}}] CHECKPOINT 2026-MM-DD HH:MM ET — Phase N / <name>` at every phase boundary AND at least every 15 minutes of active work. Each CHECKPOINT includes (a) current phase + name, (b) what's verified so far with file:line, (c) what's pending, (d) most recent worker LANDED you were about to verify. STATUS.md is the only substrate that survives panel compaction; your in-context audit state does not.
|
|
22
|
+
|
|
23
|
+
Tooling-failure fallback (Sprint 1 Codex precedent). If your shell tooling appears to have died (commands return nothing, the panel is unresponsive to `cd` or `ls`), post a final TOOLING-FAILURE CHECKPOINT to STATUS.md with everything you verified up to that point. The orchestrator can spawn a codex-rescue subagent as the verification fallback. Do NOT silently abandon — the substrate-update is the orchestrator's safety net.
|
|
24
|
+
|
|
25
|
+
Audit tasks (sprint-specific):
|
|
26
|
+
{{audit_tasks}}
|
|
27
|
+
|
|
28
|
+
Discipline — post `### [{{lane_tag}}] AUDIT-RED / AUDIT-CONCERN / CHECKPOINT / FINAL-VERDICT 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely. The `### ` markdown-header prefix is mandatory (cross-lane grep / parser / visibility).
|
|
29
|
+
|
|
30
|
+
Run `{{test_command}}` independently against worker LANDED claims — baseline is {{baseline_suite_result}}; an auditor-side regression is itself a finding worth posting.
|
|
31
|
+
|
|
32
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`. The orchestrator handles commit at sprint close.
|
|
33
|
+
|
|
34
|
+
Then begin.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
You are the orchestrator for {{sprint_name}}, running in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence:
|
|
4
|
+
1. memory_recall(project="{{project_name}}", query="{{memory_query_lane}}")
|
|
5
|
+
2. memory_recall(query="{{memory_query_broad}}")
|
|
6
|
+
3. Read ~/.claude/CLAUDE.md and ./CLAUDE.md
|
|
7
|
+
4. Read {{sprint_dir}}/PLANNING.md
|
|
8
|
+
5. Read {{sprint_dir}}/STATUS.md (the substrate you will author into all sprint long — read whole-file, not just the tail)
|
|
9
|
+
6. Read each lane brief under {{sprint_dir}}/T*.md so you can adjudicate cross-lane FINDINGs
|
|
10
|
+
|
|
11
|
+
Your responsibilities:
|
|
12
|
+
- Inject T1/T2/T3/T4 via `POST /api/sprints/inject` once the panels are open. (If that endpoint is not yet available, fall back to the documented two-stage submit pattern: bracketed-paste body via `POST /api/sessions/:id/input`, settle 400ms, then `\r` alone.)
|
|
13
|
+
- Set up a recurring polling tick (default 270s — stays inside the prompt-cache window). Use `ScheduleWakeup` or `/loop` to fire the tick. On each tick:
|
|
14
|
+
(a) Read STATUS.md whole-file (NOT tail-only — workers post inside their own sections; tail-only polling misses mid-file LANDEDs).
|
|
15
|
+
(b) Read `meta.parked` per panel via `GET /api/sessions` (NOT `meta.status` — that field is documented unreliable; it stays "active using tools" indefinitely after a Claude Code panel hits its completion banner).
|
|
16
|
+
(c) Nudge parked lanes via `POST /api/sprints/nudge` with kind=`post-landed-reminder` / `status-check` as appropriate.
|
|
17
|
+
- Adjudicate cross-lane FINDINGs via `### [ORCH] RULING 2026-MM-DD HH:MM ET — <scope decision>`. Apply GAP-vs-EXPANSION discipline: a finding that exposes a gap INSIDE a locked sub-task's intent is in-scope; a finding that needs a genuinely separate sub-task is OUT-OF-SCOPE and gets deferred.
|
|
18
|
+
- Do NOT issue `[ORCH] STATUS` proxy-LANDED on a worker's behalf unless that worker's lane has clearly died (tooling-failure fallback). The default is to nudge the worker; proxy-LANDED is a last resort.
|
|
19
|
+
- Close the sprint with a single `### [ORCH] FINAL-VERDICT GREEN/RED 2026-MM-DD HH:MM ET — <verdict + suite counts>` post AFTER the auditor's amended FINAL-VERDICT and all workers' DONE posts.
|
|
20
|
+
- Polling stops on amended FINAL-VERDICT GREEN.
|
|
21
|
+
|
|
22
|
+
After the sprint closes (FINAL-VERDICT GREEN), execute the close-out protocol yourself — do NOT rely on lanes to self-harvest (panels can die between FINAL-VERDICT and `/exit`):
|
|
23
|
+
- Read STATUS.md whole-file as the durable record of every lane's work.
|
|
24
|
+
- Extract kitchen-level (not recipe-level) memories per `~/.claude/CLAUDE.md` § "Kitchen vs recipes — memory granularity". Prefer fewer, denser, cross-cutting principles over many shallow per-lane recipes.
|
|
25
|
+
- Call `memory_remember` for each kitchen-lesson (5–8 typical for a ~1hr sprint).
|
|
26
|
+
- Version bumps, CHANGELOG, BACKLOG updates, RESTART-PROMPT, gitleaks, commit, publish hand-off, push, tag.
|
|
27
|
+
|
|
28
|
+
During the sprint: do NOT bump versions, touch CHANGELOG, or `git commit`. Those happen only at close-out.
|
|
29
|
+
|
|
30
|
+
Then begin.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
You are {{lane_tag}} in {{sprint_name}}, running in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence:
|
|
4
|
+
1. memory_recall(project="{{project_name}}", query="{{memory_query_lane}}")
|
|
5
|
+
2. memory_recall(query="{{memory_query_broad}}")
|
|
6
|
+
3. Read ~/.claude/CLAUDE.md and ./CLAUDE.md
|
|
7
|
+
4. Read {{sprint_dir}}/PLANNING.md
|
|
8
|
+
5. Read {{sprint_dir}}/STATUS.md
|
|
9
|
+
6. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative)
|
|
10
|
+
|
|
11
|
+
{{cross_lane_intel}}
|
|
12
|
+
|
|
13
|
+
Discipline — post `### [{{lane_tag}}] FINDING / PROPOSE / LANDED / DONE 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely as you work. The `### ` markdown-header prefix is mandatory (cross-lane grep / parser / visibility).
|
|
14
|
+
|
|
15
|
+
Done-when (mandatory): your task is NOT complete when local tests pass. It is complete only when ALL of these are true:
|
|
16
|
+
(a) Tests green (`{{test_command}}` ≤ baseline {{baseline_suite_result}}).
|
|
17
|
+
(b) `### [{{lane_tag}}] LANDED 2026-MM-DD HH:MM ET — <gist>` posted to {{sprint_dir}}/STATUS.md with file:line evidence + the exact test command + result.
|
|
18
|
+
(c) Auditor has had a chance to react — do not idle for 10+ minutes after posting LANDED.
|
|
19
|
+
|
|
20
|
+
The TermDeck panel-status API does not refresh when you reach your Claude Code completion banner ("Cogitated/Churned/Brewed/Cooked for Nm Ns" in gray). Your LANDED post on STATUS.md is the ONLY signal of done-ness reaching the orchestrator + auditor.
|
|
21
|
+
|
|
22
|
+
If you find a bug in another lane's code, post `### [{{lane_tag}}] FINDING 2026-MM-DD HH:MM ET → ROUTED TO T<m> — <file:line + reasoning>`. Do NOT silently work around it. Orchestrator adjudicates with `### [ORCH] RULING`.
|
|
23
|
+
|
|
24
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`. The orchestrator handles commit at sprint close.
|
|
25
|
+
|
|
26
|
+
Then begin.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
You are {{lane_tag}} (auditor) in {{sprint_name}}, running on the Codex CLI in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence:
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. memory_recall(project="{{project_name}}", query="{{memory_query_lane}}")
|
|
6
|
+
(Mnestra MCP is wired into Codex via ~/.codex/config.toml. If memory_recall is unavailable, grep ~/.claude/projects/.../memory/ as a fallback.)
|
|
7
|
+
3. memory_recall(query="{{memory_query_broad}}")
|
|
8
|
+
4. Read ./AGENTS.md (Codex's canonical project router; auto-generated mirror of CLAUDE.md).
|
|
9
|
+
5. Read {{sprint_dir}}/PLANNING.md
|
|
10
|
+
6. Read {{sprint_dir}}/STATUS.md
|
|
11
|
+
7. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative)
|
|
12
|
+
|
|
13
|
+
{{cross_lane_intel}}
|
|
14
|
+
|
|
15
|
+
Synchronize on LANDED (read this first). The build sprint produces code over time, not in one shot. Until ≥1 worker posts `### [T<n>] LANDED`, your AUDIT-REDs would be observations of the pre-build state, NOT failure findings. Do NOT issue FINAL-VERDICT until either: (a) the orchestrator closes the sprint explicitly, or (b) all worker lanes have posted DONE.
|
|
16
|
+
|
|
17
|
+
Audit cycle per worker LANDED:
|
|
18
|
+
1. Read the LANDED post + the file:line evidence it cites.
|
|
19
|
+
2. Independently reproduce — do NOT borrow the worker's test fixture. Build a minimal payload from scratch.
|
|
20
|
+
3. On broken: `### [{{lane_tag}}] AUDIT-RED 2026-MM-DD HH:MM ET — <file:line + repro + reasoning>` routed to the owning worker.
|
|
21
|
+
4. On sound: record verified-pass for that LANDED inside the next CHECKPOINT post.
|
|
22
|
+
|
|
23
|
+
CHECKPOINT mandate (load-bearing). Post `### [{{lane_tag}}] CHECKPOINT 2026-MM-DD HH:MM ET — Phase N / <name>` at every phase boundary AND at least every 15 minutes of active work. STATUS.md is the only substrate that survives panel compaction; your in-context audit state does not.
|
|
24
|
+
|
|
25
|
+
Tooling-failure fallback. If your shell or MCP tooling fails (commands return nothing, hang, etc.), post a TOOLING-FAILURE CHECKPOINT to STATUS.md with what's verified so far. The orchestrator can spawn a fallback verification subagent.
|
|
26
|
+
|
|
27
|
+
Audit tasks (sprint-specific):
|
|
28
|
+
{{audit_tasks}}
|
|
29
|
+
|
|
30
|
+
Discipline — post `### [{{lane_tag}}] AUDIT-RED / AUDIT-CONCERN / CHECKPOINT / FINAL-VERDICT 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely. The `### ` markdown-header prefix is mandatory.
|
|
31
|
+
|
|
32
|
+
Run `{{test_command}}` independently against worker LANDED claims — baseline is {{baseline_suite_result}}.
|
|
33
|
+
|
|
34
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`.
|
|
35
|
+
|
|
36
|
+
Then begin.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
You are {{lane_tag}} in {{sprint_name}}, running on the Codex CLI in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence:
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. memory_recall(project="{{project_name}}", query="{{memory_query_lane}}")
|
|
6
|
+
(Mnestra MCP is wired into Codex via ~/.codex/config.toml [mcp_servers.mnestra]. If memory_recall is unavailable, fall back to grepping the project memory directory under ~/.claude/projects/.)
|
|
7
|
+
3. memory_recall(query="{{memory_query_broad}}")
|
|
8
|
+
4. Read ./AGENTS.md (auto-generated mirror of CLAUDE.md via scripts/sync-agent-instructions.js — canonical content lives in CLAUDE.md but AGENTS.md is what Codex reads natively).
|
|
9
|
+
5. Read {{sprint_dir}}/PLANNING.md
|
|
10
|
+
6. Read {{sprint_dir}}/STATUS.md
|
|
11
|
+
7. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative)
|
|
12
|
+
|
|
13
|
+
{{cross_lane_intel}}
|
|
14
|
+
|
|
15
|
+
Discipline — post `### [{{lane_tag}}] FINDING / PROPOSE / LANDED / DONE 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely as you work. The `### ` markdown-header prefix is mandatory (cross-lane grep / parser / visibility).
|
|
16
|
+
|
|
17
|
+
Done-when (mandatory): your task is NOT complete when local tests pass. It is complete only when ALL of these are true:
|
|
18
|
+
(a) Tests green (`{{test_command}}` ≤ baseline {{baseline_suite_result}}).
|
|
19
|
+
(b) `### [{{lane_tag}}] LANDED 2026-MM-DD HH:MM ET — <gist>` posted to {{sprint_dir}}/STATUS.md with file:line evidence + the exact test command + result.
|
|
20
|
+
(c) Auditor has had a chance to react — do not idle for 10+ minutes after posting LANDED.
|
|
21
|
+
|
|
22
|
+
The TermDeck panel-status API does not refresh when you reach your Codex completion banner. Your LANDED post on STATUS.md is the ONLY signal of done-ness reaching the orchestrator + auditor.
|
|
23
|
+
|
|
24
|
+
If you find a bug in another lane's code, post `### [{{lane_tag}}] FINDING 2026-MM-DD HH:MM ET → ROUTED TO T<m> — <file:line + reasoning>`. Do NOT silently work around it. Orchestrator adjudicates with `### [ORCH] RULING`.
|
|
25
|
+
|
|
26
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`. The orchestrator handles commit at sprint close.
|
|
27
|
+
|
|
28
|
+
Then begin.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
You are {{lane_tag}} (auditor) in {{sprint_name}}, running on the Gemini CLI in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence (Gemini does NOT have Mnestra MCP by default — the brief is self-contained; start there):
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. Read ./GEMINI.md (Gemini's canonical project router).
|
|
6
|
+
3. Read {{sprint_dir}}/PLANNING.md
|
|
7
|
+
4. Read {{sprint_dir}}/STATUS.md
|
|
8
|
+
5. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative; this brief is self-contained because Gemini cannot recall memory).
|
|
9
|
+
|
|
10
|
+
{{cross_lane_intel}}
|
|
11
|
+
|
|
12
|
+
Synchronize on LANDED (read this first). The build sprint produces code over time, not in one shot. Until ≥1 worker posts `### [T<n>] LANDED`, your AUDIT-REDs would be observations of the pre-build state, NOT failure findings. Do NOT issue FINAL-VERDICT until either: (a) the orchestrator closes the sprint explicitly, or (b) all worker lanes have posted DONE.
|
|
13
|
+
|
|
14
|
+
Audit cycle per worker LANDED:
|
|
15
|
+
1. Read the LANDED post + the file:line evidence it cites.
|
|
16
|
+
2. Independently reproduce — do NOT borrow the worker's test fixture. Build a minimal payload from scratch.
|
|
17
|
+
3. On broken: `### [{{lane_tag}}] AUDIT-RED 2026-MM-DD HH:MM ET — <file:line + repro + reasoning>` routed to the owning worker.
|
|
18
|
+
4. On sound: record verified-pass for that LANDED inside the next CHECKPOINT post.
|
|
19
|
+
|
|
20
|
+
CHECKPOINT mandate (load-bearing). Post `### [{{lane_tag}}] CHECKPOINT 2026-MM-DD HH:MM ET — Phase N / <name>` at every phase boundary AND at least every 15 minutes of active work. STATUS.md is the only substrate that survives panel compaction.
|
|
21
|
+
|
|
22
|
+
Tooling-failure fallback. If your shell tooling fails, post a TOOLING-FAILURE CHECKPOINT with what's verified so far.
|
|
23
|
+
|
|
24
|
+
Audit tasks (sprint-specific):
|
|
25
|
+
{{audit_tasks}}
|
|
26
|
+
|
|
27
|
+
Discipline — post `### [{{lane_tag}}] AUDIT-RED / AUDIT-CONCERN / CHECKPOINT / FINAL-VERDICT 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely.
|
|
28
|
+
|
|
29
|
+
Run `{{test_command}}` independently against worker LANDED claims — baseline is {{baseline_suite_result}}.
|
|
30
|
+
|
|
31
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`.
|
|
32
|
+
|
|
33
|
+
Then begin.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
You are {{lane_tag}} in {{sprint_name}}, running on the Gemini CLI in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence (Gemini does NOT have Mnestra MCP by default — the lane brief is self-contained; start there):
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. Read ./GEMINI.md (Gemini's canonical project router — auto-generated mirror of CLAUDE.md via scripts/sync-agent-instructions.js).
|
|
6
|
+
3. Read {{sprint_dir}}/PLANNING.md
|
|
7
|
+
4. Read {{sprint_dir}}/STATUS.md
|
|
8
|
+
5. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative; this brief is self-contained because Gemini cannot recall memory).
|
|
9
|
+
|
|
10
|
+
{{cross_lane_intel}}
|
|
11
|
+
|
|
12
|
+
Discipline — post `### [{{lane_tag}}] FINDING / PROPOSE / LANDED / DONE 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely as you work. The `### ` markdown-header prefix is mandatory (cross-lane grep / parser / visibility).
|
|
13
|
+
|
|
14
|
+
Done-when (mandatory): your task is NOT complete when local tests pass. It is complete only when ALL of these are true:
|
|
15
|
+
(a) Tests green (`{{test_command}}` ≤ baseline {{baseline_suite_result}}).
|
|
16
|
+
(b) `### [{{lane_tag}}] LANDED 2026-MM-DD HH:MM ET — <gist>` posted to {{sprint_dir}}/STATUS.md with file:line evidence + the exact test command + result.
|
|
17
|
+
(c) Auditor has had a chance to react — do not idle for 10+ minutes after posting LANDED.
|
|
18
|
+
|
|
19
|
+
The TermDeck panel-status API does not refresh when you reach your Gemini completion banner. Your LANDED post on STATUS.md is the ONLY signal of done-ness reaching the orchestrator + auditor.
|
|
20
|
+
|
|
21
|
+
If you find a bug in another lane's code, post `### [{{lane_tag}}] FINDING 2026-MM-DD HH:MM ET → ROUTED TO T<m> — <file:line + reasoning>`. Do NOT silently work around it. Orchestrator adjudicates with `### [ORCH] RULING`.
|
|
22
|
+
|
|
23
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`. The orchestrator handles commit at sprint close.
|
|
24
|
+
|
|
25
|
+
Then begin.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
You are {{lane_tag}} (auditor) in {{sprint_name}}, running on the Grok CLI (SuperGrok Heavy) in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence (Grok does NOT have Mnestra MCP by default — the brief is self-contained; start there):
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. Read ./AGENTS.md (Grok's canonical project router, shared with Codex).
|
|
6
|
+
3. Read {{sprint_dir}}/PLANNING.md
|
|
7
|
+
4. Read {{sprint_dir}}/STATUS.md
|
|
8
|
+
5. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative; this brief is self-contained because Grok cannot recall memory).
|
|
9
|
+
|
|
10
|
+
{{cross_lane_intel}}
|
|
11
|
+
|
|
12
|
+
Adversarial mindset (load-bearing — this is your single most important responsibility). You are the only model in this sprint NOT sharing training history with the worker lanes. Your audit ROI comes from independently reproducing every claim — do NOT borrow worker fixtures, do NOT trust worker self-tests, do NOT accept "tests green" as evidence the design is sound. Worker LANDED posts are claims; you verify with your own minimal payloads against the actual disk state.
|
|
13
|
+
|
|
14
|
+
Synchronize on LANDED (read this first). The build sprint produces code over time, not in one shot. Until ≥1 worker posts `### [T<n>] LANDED`, your AUDIT-REDs would be observations of the pre-build state, NOT failure findings. Do NOT issue FINAL-VERDICT until either: (a) the orchestrator closes the sprint explicitly, or (b) all worker lanes have posted DONE.
|
|
15
|
+
|
|
16
|
+
Audit cycle per worker LANDED:
|
|
17
|
+
1. Read the LANDED post + the file:line evidence it cites.
|
|
18
|
+
2. Reproduce from scratch — build a minimal payload yourself, do NOT borrow worker fixtures.
|
|
19
|
+
3. On broken: `### [{{lane_tag}}] AUDIT-RED 2026-MM-DD HH:MM ET — <file:line + repro + reasoning>` routed to the owning worker.
|
|
20
|
+
4. On sound: record verified-pass for that LANDED inside the next CHECKPOINT post.
|
|
21
|
+
|
|
22
|
+
CHECKPOINT mandate (load-bearing). Post `### [{{lane_tag}}] CHECKPOINT 2026-MM-DD HH:MM ET — Phase N / <name>` at every phase boundary AND at least every 15 minutes of active work. STATUS.md is the only substrate that survives panel compaction.
|
|
23
|
+
|
|
24
|
+
Tooling-failure fallback. If your shell tooling fails, post a TOOLING-FAILURE CHECKPOINT with what's verified so far. The orchestrator can spawn a fallback verification subagent.
|
|
25
|
+
|
|
26
|
+
Audit tasks (sprint-specific):
|
|
27
|
+
{{audit_tasks}}
|
|
28
|
+
|
|
29
|
+
Discipline — post `### [{{lane_tag}}] AUDIT-RED / AUDIT-CONCERN / CHECKPOINT / FINAL-VERDICT 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely.
|
|
30
|
+
|
|
31
|
+
Run `{{test_command}}` independently against worker LANDED claims — baseline is {{baseline_suite_result}}.
|
|
32
|
+
|
|
33
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`.
|
|
34
|
+
|
|
35
|
+
Then begin.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
You are {{lane_tag}} in {{sprint_name}}, running on the Grok CLI (SuperGrok Heavy) in {{project_name}} at {{project_path}}.
|
|
2
|
+
|
|
3
|
+
Boot sequence (Grok does NOT have Mnestra MCP by default — the lane brief is self-contained; start there):
|
|
4
|
+
1. Run `date` to time-stamp.
|
|
5
|
+
2. Read ./AGENTS.md (Grok's canonical project router; auto-generated mirror of CLAUDE.md, shared with Codex).
|
|
6
|
+
3. Read {{sprint_dir}}/PLANNING.md
|
|
7
|
+
4. Read {{sprint_dir}}/STATUS.md
|
|
8
|
+
5. Read {{sprint_dir}}/{{lane_brief}} (your full briefing — authoritative; this brief is self-contained because Grok cannot recall memory).
|
|
9
|
+
|
|
10
|
+
{{cross_lane_intel}}
|
|
11
|
+
|
|
12
|
+
Discipline — post `### [{{lane_tag}}] FINDING / PROPOSE / LANDED / DONE 2026-MM-DD HH:MM ET — <gist>` to {{sprint_dir}}/STATUS.md verbosely as you work. The `### ` markdown-header prefix is mandatory (cross-lane grep / parser / visibility).
|
|
13
|
+
|
|
14
|
+
Done-when (mandatory): your task is NOT complete when local tests pass. It is complete only when ALL of these are true:
|
|
15
|
+
(a) Tests green (`{{test_command}}` ≤ baseline {{baseline_suite_result}}).
|
|
16
|
+
(b) `### [{{lane_tag}}] LANDED 2026-MM-DD HH:MM ET — <gist>` posted to {{sprint_dir}}/STATUS.md with file:line evidence + the exact test command + result.
|
|
17
|
+
(c) Auditor has had a chance to react — do not idle for 10+ minutes after posting LANDED.
|
|
18
|
+
|
|
19
|
+
The TermDeck panel-status API does not refresh when you reach your Grok completion banner. Your LANDED post on STATUS.md is the ONLY signal of done-ness reaching the orchestrator + auditor.
|
|
20
|
+
|
|
21
|
+
If you find a bug in another lane's code, post `### [{{lane_tag}}] FINDING 2026-MM-DD HH:MM ET → ROUTED TO T<m> — <file:line + reasoning>`. Do NOT silently work around it. Orchestrator adjudicates with `### [ORCH] RULING`.
|
|
22
|
+
|
|
23
|
+
Don't bump versions, don't touch CHANGELOG, don't `git commit`. The orchestrator handles commit at sprint close.
|
|
24
|
+
|
|
25
|
+
Then begin.
|
|
@@ -93,6 +93,13 @@ const { themes, statusColors } = require('./themes');
|
|
|
93
93
|
const { loadConfig, addProject, removeProject, updateConfig } = require('./config');
|
|
94
94
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
95
95
|
const { createSprintRoutes } = require('./sprint-routes');
|
|
96
|
+
const { createSprintInjectRoutes } = require('./sprints/inject');
|
|
97
|
+
// Sprint 69 T1 — boot-prompt template engine. Exposed at the public surface
|
|
98
|
+
// so external callers (T2's inject route, integration tests, future tools)
|
|
99
|
+
// can do `require('@termdeck/server').templateEngine` instead of reaching
|
|
100
|
+
// into the internal `./templates/template-engine` path.
|
|
101
|
+
const templateEngine = require('./templates/template-engine');
|
|
102
|
+
const { createSprintNudgeRoutes } = require('./sprints/nudge');
|
|
96
103
|
const { createGraphRoutes } = require('./graph-routes');
|
|
97
104
|
const { createProjectsRoutes } = require('./projects-routes');
|
|
98
105
|
const orchestrationPreview = require('./orchestration-preview');
|
|
@@ -1734,6 +1741,14 @@ function createServer(config) {
|
|
|
1734
1741
|
spawnTerminalSession,
|
|
1735
1742
|
getSession: (id) => sessions.get(id),
|
|
1736
1743
|
});
|
|
1744
|
+
createSprintInjectRoutes({
|
|
1745
|
+
app,
|
|
1746
|
+
getSession: (id) => sessions.get(id),
|
|
1747
|
+
});
|
|
1748
|
+
createSprintNudgeRoutes({
|
|
1749
|
+
app,
|
|
1750
|
+
getSession: (id) => sessions.get(id),
|
|
1751
|
+
});
|
|
1737
1752
|
|
|
1738
1753
|
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
1739
1754
|
// Reuses the daily-driver pg pool (same DATABASE_URL serves memory_items +
|
|
@@ -3062,6 +3077,10 @@ module.exports = {
|
|
|
3062
3077
|
SECRETS_EXCLUDED_FROM_PTY,
|
|
3063
3078
|
// Sprint 65 T2 (2.1) — operator-role whitelist, exported for the route fence.
|
|
3064
3079
|
ALLOWED_SESSION_ROLES,
|
|
3080
|
+
// Sprint 69 T1 — boot-prompt template engine. Exported so T2's inject
|
|
3081
|
+
// endpoint and integration tests can import without traversing the
|
|
3082
|
+
// internal `./templates/template-engine` path.
|
|
3083
|
+
templateEngine,
|
|
3065
3084
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
3066
3085
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
3067
3086
|
// payload shape, fire-and-forget).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parked-lane detection for TermDeck.
|
|
3
|
+
*
|
|
4
|
+
* Algorithm:
|
|
5
|
+
* - If session status is not "active", it's not parked (it might be thinking, editing, or exited).
|
|
6
|
+
* - If lastActivity was within the last 5 minutes, it's genuinely active.
|
|
7
|
+
* - Otherwise, parse the trailing output buffer for Claude Code's completion banners.
|
|
8
|
+
* - If matched, it's parked.
|
|
9
|
+
*
|
|
10
|
+
* Completion banners (Claude Code):
|
|
11
|
+
* - "Cogitated for 1m 2s"
|
|
12
|
+
* - "Churned for 5m 10s"
|
|
13
|
+
* - Verbs: Cogitated, Churned, Brewed, Cooked, Mused, Pondered, Wandered, Crafted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { stripAnsi } = require('./transcripts');
|
|
17
|
+
|
|
18
|
+
function detectParked(session) {
|
|
19
|
+
if (!session || !session.meta) return false;
|
|
20
|
+
|
|
21
|
+
// Only "active" (PTY-wise) sessions can be "parked" (semantic-wise).
|
|
22
|
+
// "thinking" or "editing" statuses are already semantic indicators
|
|
23
|
+
// of work-in-progress.
|
|
24
|
+
if (session.meta.status !== 'active') return false;
|
|
25
|
+
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const lastActivity = new Date(session.meta.lastActivity).getTime();
|
|
28
|
+
const ageMs = now - lastActivity;
|
|
29
|
+
|
|
30
|
+
// Threshold: 5 minutes.
|
|
31
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
32
|
+
if (ageMs < FIVE_MIN_MS) return false;
|
|
33
|
+
|
|
34
|
+
// Read the session's output buffer (last ~4KB preserved in Session.analyzeOutput).
|
|
35
|
+
const buffer = session._outputBuffer || '';
|
|
36
|
+
if (!buffer) return false;
|
|
37
|
+
|
|
38
|
+
// Strip ANSI to match the plain-text banner
|
|
39
|
+
const cleanBuffer = stripAnsi(buffer);
|
|
40
|
+
|
|
41
|
+
// Regex per BRIEF + PLANNING:
|
|
42
|
+
// (Cogitated|Churned|Brewed|Cooked|Mused|Pondered|Wandered|Crafted) for \d+m \d+s
|
|
43
|
+
const PARKED_BANNER_RE = /(?:Cogitated|Churned|Brewed|Cooked|Mused|Pondered|Wandered|Crafted) for \d+m \d+s/i;
|
|
44
|
+
|
|
45
|
+
// Look in the last ~1000 chars of the cleaned buffer.
|
|
46
|
+
const tail = cleanBuffer.slice(-1000);
|
|
47
|
+
return PARKED_BANNER_RE.test(tail);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { detectParked };
|
|
@@ -18,6 +18,8 @@ const flashbackDiag = require('./flashback-diag');
|
|
|
18
18
|
const geminiAdapter = require('./agent-adapters/gemini');
|
|
19
19
|
const { detectAdapter, getAdapterForSessionType } = require('./agent-adapters');
|
|
20
20
|
|
|
21
|
+
const { detectParked } = require("./parked-detection");
|
|
22
|
+
|
|
21
23
|
// Strip ANSI escape codes for pattern matching
|
|
22
24
|
function stripAnsi(str) {
|
|
23
25
|
return str
|
|
@@ -540,8 +542,10 @@ class Session {
|
|
|
540
542
|
if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
|
|
541
543
|
meta.status = 'idle';
|
|
542
544
|
meta.statusDetail = '';
|
|
545
|
+
meta.parked = true;
|
|
543
546
|
}
|
|
544
547
|
}
|
|
548
|
+
meta.parked = detectParked(this);
|
|
545
549
|
return {
|
|
546
550
|
id: this.id,
|
|
547
551
|
pid: this.pid,
|
|
@@ -22,6 +22,7 @@ const os = require('os');
|
|
|
22
22
|
const { execFileSync } = require('child_process');
|
|
23
23
|
|
|
24
24
|
const { injectSprintPrompts } = require('./sprint-inject');
|
|
25
|
+
const { parseStatusMd: parseStatusMdV2 } = require('./sprints/status-parser');
|
|
25
26
|
|
|
26
27
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
27
28
|
|
|
@@ -428,6 +429,22 @@ function createSprintRoutes({ app, config, spawnTerminalSession, getSession }) {
|
|
|
428
429
|
res.json({ project, sprints });
|
|
429
430
|
});
|
|
430
431
|
|
|
432
|
+
|
|
433
|
+
// GET /api/sprints/status?file=<path> — new structured parser (Sprint 69 T3)
|
|
434
|
+
app.get('/api/sprints/status', (req, res) => {
|
|
435
|
+
const filePath = req.query.file;
|
|
436
|
+
if (!filePath) return res.status(400).json({ error: 'file query param required' });
|
|
437
|
+
if (!fs.existsSync(filePath)) {
|
|
438
|
+
return res.status(404).json({ error: `file not found: ${filePath}` });
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const parsed = parseStatusMdV2(filePath);
|
|
442
|
+
res.json(parsed);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
res.status(500).json({ error: `parse failed: ${err.message}` });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
431
448
|
// GET /api/sprints/:name/status?project=foo — parse STATUS.md per-lane.
|
|
432
449
|
app.get('/api/sprints/:name/status', (req, res) => {
|
|
433
450
|
const project = req.query.project;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SUBMIT_OPTIONS = {
|
|
4
|
+
gapMs: 250,
|
|
5
|
+
settleMs: 400,
|
|
6
|
+
snapshotDelayMs: 5000,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const ALLOWED_ROLES = new Set(['worker', 'auditor', 'orchestrator']);
|
|
10
|
+
|
|
11
|
+
class SprintRequestError extends Error {
|
|
12
|
+
constructor(message, statusCode = 400, details = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'SprintRequestError';
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
this.details = details;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultSleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeCliType(type) {
|
|
29
|
+
if (type === 'claude') return 'claude-code';
|
|
30
|
+
return type || 'shell';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateInjectBody(body) {
|
|
34
|
+
const input = isPlainObject(body) ? body : {};
|
|
35
|
+
if (!Array.isArray(input.panels) || input.panels.length === 0) {
|
|
36
|
+
throw new SprintRequestError('panels must be a non-empty array');
|
|
37
|
+
}
|
|
38
|
+
if (!isPlainObject(input.variables)) {
|
|
39
|
+
throw new SprintRequestError('variables must be an object');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const panels = input.panels.map((panel, index) => {
|
|
43
|
+
if (!isPlainObject(panel)) {
|
|
44
|
+
throw new SprintRequestError(`panels[${index}] must be an object`);
|
|
45
|
+
}
|
|
46
|
+
const tag = typeof panel.tag === 'string' ? panel.tag.trim() : '';
|
|
47
|
+
const sessionId = typeof panel.sessionId === 'string' ? panel.sessionId.trim() : '';
|
|
48
|
+
const role = typeof panel.role === 'string' ? panel.role.trim() : '';
|
|
49
|
+
const laneBrief = typeof panel.lane_brief === 'string' ? panel.lane_brief.trim() : '';
|
|
50
|
+
|
|
51
|
+
if (!tag) throw new SprintRequestError(`panels[${index}].tag is required`);
|
|
52
|
+
if (!sessionId) throw new SprintRequestError(`panels[${index}].sessionId is required`);
|
|
53
|
+
if (!ALLOWED_ROLES.has(role)) {
|
|
54
|
+
throw new SprintRequestError(
|
|
55
|
+
`panels[${index}].role must be one of: ${Array.from(ALLOWED_ROLES).join(', ')}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!laneBrief) throw new SprintRequestError(`panels[${index}].lane_brief is required`);
|
|
59
|
+
|
|
60
|
+
return { tag, sessionId, role, lane_brief: laneBrief };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { panels, variables: { ...input.variables } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePanelSessions(panels, getSession) {
|
|
67
|
+
if (typeof getSession !== 'function') {
|
|
68
|
+
throw new Error('getSession(sessionId) callback required');
|
|
69
|
+
}
|
|
70
|
+
return panels.map((panel) => {
|
|
71
|
+
const session = getSession(panel.sessionId);
|
|
72
|
+
if (!session) {
|
|
73
|
+
throw new SprintRequestError(`session not found: ${panel.sessionId}`, 400, {
|
|
74
|
+
code: 'invalid_session',
|
|
75
|
+
tag: panel.tag,
|
|
76
|
+
sessionId: panel.sessionId,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return session;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultLoadTemplate(cliType, role, variables) {
|
|
84
|
+
// T1 owns this module in Sprint 69. Resolve lazily so T2's route can load
|
|
85
|
+
// before T1's engine has landed; endpoint calls surface a clear error.
|
|
86
|
+
const engine = require('../templates/template-engine');
|
|
87
|
+
if (!engine || typeof engine.loadTemplate !== 'function') {
|
|
88
|
+
throw new Error('template-engine must export loadTemplate(cliType, role, variables)');
|
|
89
|
+
}
|
|
90
|
+
return engine.loadTemplate(cliType, role, variables);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeMissingVariables(err) {
|
|
94
|
+
if (!err) return [];
|
|
95
|
+
const raw =
|
|
96
|
+
err.missingVariables
|
|
97
|
+
|| err.missing_variables
|
|
98
|
+
|| err.variables
|
|
99
|
+
|| err.variableNames
|
|
100
|
+
|| err.missing;
|
|
101
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
102
|
+
if (typeof raw === 'string' && raw) return [raw];
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mapTemplateError(err) {
|
|
107
|
+
const message = err && err.message ? err.message : String(err);
|
|
108
|
+
const missingVariables = normalizeMissingVariables(err);
|
|
109
|
+
const lower = message.toLowerCase();
|
|
110
|
+
const name = err && err.name;
|
|
111
|
+
const code = err && err.code;
|
|
112
|
+
|
|
113
|
+
if (code === 'MODULE_NOT_FOUND' && /template-engine/.test(message)) {
|
|
114
|
+
return new SprintRequestError('template engine unavailable', 503, {
|
|
115
|
+
code: 'template_engine_unavailable',
|
|
116
|
+
detail: message,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
name === 'MissingVariableError'
|
|
122
|
+
|| code === 'missing_variable'
|
|
123
|
+
|| code === 'missing_variables'
|
|
124
|
+
|| missingVariables.length > 0
|
|
125
|
+
|| (lower.includes('missing') && lower.includes('variable'))
|
|
126
|
+
) {
|
|
127
|
+
return new SprintRequestError(message, 400, {
|
|
128
|
+
code: 'missing_template_variables',
|
|
129
|
+
missingVariables,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
code === 'unknown_template'
|
|
135
|
+
|| code === 'unknown_cli_type'
|
|
136
|
+
|| code === 'unknown_role'
|
|
137
|
+
|| lower.includes('unknown template')
|
|
138
|
+
|| lower.includes('unknown cli')
|
|
139
|
+
|| lower.includes('unknown role')
|
|
140
|
+
|| lower.includes('template not found')
|
|
141
|
+
) {
|
|
142
|
+
return new SprintRequestError(message, 400, { code: 'template_error' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return err;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function renderInjectPanels({ panels, variables, sessions, loadTemplate }) {
|
|
149
|
+
const loader = loadTemplate || defaultLoadTemplate;
|
|
150
|
+
const rendered = [];
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < panels.length; i++) {
|
|
153
|
+
const panel = panels[i];
|
|
154
|
+
const session = sessions[i];
|
|
155
|
+
const cliType = normalizeCliType(session && session.meta && session.meta.type);
|
|
156
|
+
const templateVars = {
|
|
157
|
+
...variables,
|
|
158
|
+
lane_brief: panel.lane_brief,
|
|
159
|
+
lane_tag: panel.tag,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const text = await Promise.resolve(loader(cliType, panel.role, templateVars));
|
|
164
|
+
if (typeof text !== 'string') {
|
|
165
|
+
throw new Error('loadTemplate must return a string');
|
|
166
|
+
}
|
|
167
|
+
rendered.push({ ...panel, cliType, text });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw mapTemplateError(err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return rendered;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sessionSnapshot(panel, session) {
|
|
177
|
+
const meta = (session && session.meta) || {};
|
|
178
|
+
return {
|
|
179
|
+
tag: panel.tag,
|
|
180
|
+
sessionId: panel.sessionId,
|
|
181
|
+
status: meta.status || null,
|
|
182
|
+
statusDetail: meta.statusDetail || '',
|
|
183
|
+
lastActivity: meta.lastActivity || null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createDefaultWriteInput(getSession) {
|
|
188
|
+
return async ({ sessionId, text }) => {
|
|
189
|
+
const session = getSession(sessionId);
|
|
190
|
+
if (!session) {
|
|
191
|
+
throw new SprintRequestError(`session not found: ${sessionId}`, 400, {
|
|
192
|
+
code: 'invalid_session',
|
|
193
|
+
sessionId,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (!session.pty || (session.meta && session.meta.status === 'exited')) {
|
|
197
|
+
throw new SprintRequestError(`Panel ${sessionId} has exited`, 410, {
|
|
198
|
+
code: 'panel_exited',
|
|
199
|
+
sessionId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
session.pty.write(text);
|
|
204
|
+
if (typeof session.trackInput === 'function') session.trackInput(text);
|
|
205
|
+
session.meta.replyCount = (session.meta.replyCount || 0) + 1;
|
|
206
|
+
return { ok: true, bytes: text.length, replyCount: session.meta.replyCount };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw new SprintRequestError(err && err.message ? err.message : String(err), 500, {
|
|
209
|
+
code: 'write_failed',
|
|
210
|
+
sessionId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function runTwoStageSubmit({
|
|
217
|
+
panels,
|
|
218
|
+
getSession,
|
|
219
|
+
writeInput,
|
|
220
|
+
sleep,
|
|
221
|
+
options,
|
|
222
|
+
source,
|
|
223
|
+
}) {
|
|
224
|
+
const opts = { ...DEFAULT_SUBMIT_OPTIONS, ...(options || {}) };
|
|
225
|
+
const wait = sleep || defaultSleep;
|
|
226
|
+
const write = writeInput || createDefaultWriteInput(getSession);
|
|
227
|
+
|
|
228
|
+
if (!Array.isArray(panels) || panels.length === 0) {
|
|
229
|
+
throw new SprintRequestError('panels must be a non-empty array');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < panels.length; i++) {
|
|
233
|
+
const panel = panels[i];
|
|
234
|
+
await write({
|
|
235
|
+
sessionId: panel.sessionId,
|
|
236
|
+
text: `\x1b[200~${panel.text}\x1b[201~`,
|
|
237
|
+
source: source || 'sprint',
|
|
238
|
+
stage: 'paste',
|
|
239
|
+
panel,
|
|
240
|
+
});
|
|
241
|
+
if (i < panels.length - 1) await wait(opts.gapMs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await wait(opts.settleMs);
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < panels.length; i++) {
|
|
247
|
+
const panel = panels[i];
|
|
248
|
+
await write({
|
|
249
|
+
sessionId: panel.sessionId,
|
|
250
|
+
text: '\r',
|
|
251
|
+
source: source || 'sprint',
|
|
252
|
+
stage: 'submit',
|
|
253
|
+
panel,
|
|
254
|
+
});
|
|
255
|
+
if (i < panels.length - 1) await wait(opts.gapMs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (opts.snapshotDelayMs > 0) await wait(opts.snapshotDelayMs);
|
|
259
|
+
|
|
260
|
+
return panels.map((panel) => sessionSnapshot(panel, getSession(panel.sessionId)));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sendError(res, err) {
|
|
264
|
+
const mapped = err instanceof SprintRequestError ? err : new SprintRequestError(
|
|
265
|
+
err && err.message ? err.message : String(err),
|
|
266
|
+
err && err.statusCode ? err.statusCode : 500,
|
|
267
|
+
err && err.details ? err.details : {},
|
|
268
|
+
);
|
|
269
|
+
return res.status(mapped.statusCode).json({
|
|
270
|
+
ok: false,
|
|
271
|
+
error: mapped.message,
|
|
272
|
+
...(mapped.details || {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function createSprintInjectHandler({ getSession, loadTemplate, writeInput, sleep, options } = {}) {
|
|
277
|
+
return async (req, res) => {
|
|
278
|
+
let parsed;
|
|
279
|
+
let sessions;
|
|
280
|
+
try {
|
|
281
|
+
parsed = validateInjectBody(req.body || {});
|
|
282
|
+
sessions = resolvePanelSessions(parsed.panels, getSession);
|
|
283
|
+
const rendered = await renderInjectPanels({
|
|
284
|
+
panels: parsed.panels,
|
|
285
|
+
variables: parsed.variables,
|
|
286
|
+
sessions,
|
|
287
|
+
loadTemplate,
|
|
288
|
+
});
|
|
289
|
+
const snapshots = await runTwoStageSubmit({
|
|
290
|
+
panels: rendered,
|
|
291
|
+
getSession,
|
|
292
|
+
writeInput,
|
|
293
|
+
sleep,
|
|
294
|
+
options,
|
|
295
|
+
source: 'sprint-inject',
|
|
296
|
+
});
|
|
297
|
+
return res.json({ ok: true, panels: snapshots });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return sendError(res, err);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function createSprintInjectRoutes(opts) {
|
|
305
|
+
if (!opts || !opts.app) throw new Error('app required');
|
|
306
|
+
opts.app.post('/api/sprints/inject', createSprintInjectHandler(opts));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
ALLOWED_ROLES,
|
|
311
|
+
DEFAULT_SUBMIT_OPTIONS,
|
|
312
|
+
SprintRequestError,
|
|
313
|
+
createDefaultWriteInput,
|
|
314
|
+
createSprintInjectHandler,
|
|
315
|
+
createSprintInjectRoutes,
|
|
316
|
+
defaultLoadTemplate,
|
|
317
|
+
normalizeCliType,
|
|
318
|
+
renderInjectPanels,
|
|
319
|
+
resolvePanelSessions,
|
|
320
|
+
runTwoStageSubmit,
|
|
321
|
+
sendError,
|
|
322
|
+
validateInjectBody,
|
|
323
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SprintRequestError,
|
|
5
|
+
resolvePanelSessions,
|
|
6
|
+
runTwoStageSubmit,
|
|
7
|
+
sendError,
|
|
8
|
+
} = require('./inject');
|
|
9
|
+
|
|
10
|
+
const ALLOWED_NUDGE_KINDS = new Set([
|
|
11
|
+
'post-landed-reminder',
|
|
12
|
+
'status-check',
|
|
13
|
+
'tooling-failure-recover',
|
|
14
|
+
'custom',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function isPlainObject(value) {
|
|
18
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateNudgeBody(body) {
|
|
22
|
+
const input = isPlainObject(body) ? body : {};
|
|
23
|
+
if (!Array.isArray(input.panels) || input.panels.length === 0) {
|
|
24
|
+
throw new SprintRequestError('panels must be a non-empty array');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const kind = typeof input.kind === 'string' ? input.kind.trim() : '';
|
|
28
|
+
if (!ALLOWED_NUDGE_KINDS.has(kind)) {
|
|
29
|
+
throw new SprintRequestError(
|
|
30
|
+
`kind must be one of: ${Array.from(ALLOWED_NUDGE_KINDS).join(', ')}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const panels = input.panels.map((panel, index) => {
|
|
35
|
+
if (!isPlainObject(panel)) {
|
|
36
|
+
throw new SprintRequestError(`panels[${index}] must be an object`);
|
|
37
|
+
}
|
|
38
|
+
const tag = typeof panel.tag === 'string' ? panel.tag.trim() : '';
|
|
39
|
+
const sessionId = typeof panel.sessionId === 'string' ? panel.sessionId.trim() : '';
|
|
40
|
+
if (!tag) throw new SprintRequestError(`panels[${index}].tag is required`);
|
|
41
|
+
if (!sessionId) throw new SprintRequestError(`panels[${index}].sessionId is required`);
|
|
42
|
+
return { tag, sessionId };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const context = isPlainObject(input.context) ? input.context : {};
|
|
46
|
+
if (kind === 'custom') {
|
|
47
|
+
const text = typeof input.text === 'string'
|
|
48
|
+
? input.text
|
|
49
|
+
: (typeof context.custom_text === 'string' ? context.custom_text : context.customText);
|
|
50
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
51
|
+
throw new SprintRequestError('custom nudge requires text or context.custom_text');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (kind === 'post-landed-reminder') {
|
|
55
|
+
if (!context.open_red) {
|
|
56
|
+
throw new SprintRequestError('post-landed-reminder requires context.open_red');
|
|
57
|
+
}
|
|
58
|
+
if (!context.test_repro && !context.testRepro) {
|
|
59
|
+
throw new SprintRequestError('post-landed-reminder requires context.test_repro');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { panels, kind, context, text: input.text };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function valueFromMaybeObject(value, preferredKeys) {
|
|
67
|
+
if (typeof value === 'string') return value;
|
|
68
|
+
if (!isPlainObject(value)) return String(value || '');
|
|
69
|
+
for (const key of preferredKeys) {
|
|
70
|
+
if (typeof value[key] === 'string' && value[key]) return value[key];
|
|
71
|
+
}
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildNudgeText({ panel, kind, context, text }) {
|
|
76
|
+
if (kind === 'custom') {
|
|
77
|
+
return typeof text === 'string' ? text : (context.custom_text || context.customText);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (kind === 'post-landed-reminder') {
|
|
81
|
+
const sprintName = context.sprint_name || context.sprintName || 'current sprint';
|
|
82
|
+
const fileLine = valueFromMaybeObject(context.open_red, ['file_line', 'fileLine', 'line']);
|
|
83
|
+
const testRepro = context.test_repro || context.testRepro;
|
|
84
|
+
return [
|
|
85
|
+
`ORCHESTRATOR NUDGE — ${sprintName}.`,
|
|
86
|
+
`${panel.tag}: T4 audit found ${fileLine} with repro ${testRepro}.`,
|
|
87
|
+
`Your fix should land as \`### [${panel.tag}] LANDED ...\` to STATUS.md once tests pass and the auditor has reacted.`,
|
|
88
|
+
].join(' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (kind === 'status-check') {
|
|
92
|
+
const minutes = context.silent_minutes || context.silentMinutes || 'several';
|
|
93
|
+
return [
|
|
94
|
+
'ORCHESTRATOR STATUS-CHECK.',
|
|
95
|
+
`STATUS.md has been silent for ${minutes} minutes.`,
|
|
96
|
+
`Post a \`### [${panel.tag}] CHECKPOINT\` with your current progress, or \`LANDED\` if done.`,
|
|
97
|
+
].join(' ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
'ORCHESTRATOR RECOVERY — your shell tooling appears to have died.',
|
|
102
|
+
'POST a final TOOLING-FAILURE CHECKPOINT to STATUS.md with what you have verified so far.',
|
|
103
|
+
'The orchestrator will spawn a codex-rescue subagent as the verification fallback.',
|
|
104
|
+
].join(' ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createSprintNudgeHandler({ getSession, writeInput, sleep, options } = {}) {
|
|
108
|
+
return async (req, res) => {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = validateNudgeBody(req.body || {});
|
|
111
|
+
resolvePanelSessions(parsed.panels, getSession);
|
|
112
|
+
const panels = parsed.panels.map((panel) => ({
|
|
113
|
+
...panel,
|
|
114
|
+
text: buildNudgeText({
|
|
115
|
+
panel,
|
|
116
|
+
kind: parsed.kind,
|
|
117
|
+
context: parsed.context,
|
|
118
|
+
text: parsed.text,
|
|
119
|
+
}),
|
|
120
|
+
}));
|
|
121
|
+
const snapshots = await runTwoStageSubmit({
|
|
122
|
+
panels,
|
|
123
|
+
getSession,
|
|
124
|
+
writeInput,
|
|
125
|
+
sleep,
|
|
126
|
+
options,
|
|
127
|
+
source: 'sprint-nudge',
|
|
128
|
+
});
|
|
129
|
+
return res.json({ ok: true, panels: snapshots });
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return sendError(res, err);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createSprintNudgeRoutes(opts) {
|
|
137
|
+
if (!opts || !opts.app) throw new Error('app required');
|
|
138
|
+
opts.app.post('/api/sprints/nudge', createSprintNudgeHandler(opts));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
ALLOWED_NUDGE_KINDS,
|
|
143
|
+
buildNudgeText,
|
|
144
|
+
createSprintNudgeHandler,
|
|
145
|
+
createSprintNudgeRoutes,
|
|
146
|
+
validateNudgeBody,
|
|
147
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STATUS.md parser for TermDeck.
|
|
3
|
+
*
|
|
4
|
+
* Scans the whole file to extract the latest state for each lane.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
function parseStatusMd(filePath) {
|
|
10
|
+
const result = {
|
|
11
|
+
lanes: {},
|
|
12
|
+
open_red_count: 0,
|
|
13
|
+
last_orchestrator_post: null,
|
|
14
|
+
last_final_verdict: null
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(filePath)) {
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
22
|
+
const lines = content.split('\n');
|
|
23
|
+
|
|
24
|
+
// Regex per BRIEF (loosened for multiple suffixes):
|
|
25
|
+
// ^### \[(T\d+(?:-[A-Z0-9-]+)?|ORCH)\] (FINDING|PROPOSE|LANDED|DONE|AUDIT-RED|AUDIT-CONCERN|CHECKPOINT|FINAL-VERDICT|STATUS|RULING) (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) ET — (.*?)$
|
|
26
|
+
const POST_RE = /^### \[(T\d+(?:-[A-Z0-9-]+)?|ORCH)\] (FINDING|PROPOSE|LANDED|DONE|AUDIT-RED|AUDIT-CONCERN|CHECKPOINT|FINAL-VERDICT|STATUS|RULING) (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) ET — (.*?)$/;
|
|
27
|
+
|
|
28
|
+
const laneLandeds = {}; // laneTag -> latest LANDED timestamp
|
|
29
|
+
const openReds = []; // List of {tag, timestamp, gist}
|
|
30
|
+
|
|
31
|
+
lines.forEach((line, index) => {
|
|
32
|
+
const match = line.match(POST_RE);
|
|
33
|
+
if (!match) return;
|
|
34
|
+
|
|
35
|
+
const [full, tag, verb, date, time, gist] = match;
|
|
36
|
+
const timestamp = `${date}T${time}:00`;
|
|
37
|
+
const lineNum = index + 1;
|
|
38
|
+
|
|
39
|
+
if (tag === 'ORCH') {
|
|
40
|
+
result.last_orchestrator_post = gist;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ensure lane entry
|
|
45
|
+
if (!result.lanes[tag]) {
|
|
46
|
+
result.lanes[tag] = {
|
|
47
|
+
last_post: null,
|
|
48
|
+
open_reds_against_me: [],
|
|
49
|
+
landed_since_last_red: false
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
result.lanes[tag].last_post = {
|
|
54
|
+
verb,
|
|
55
|
+
timestamp: `${date}T${time}:00-04:00`,
|
|
56
|
+
line: lineNum,
|
|
57
|
+
gist
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (verb === 'LANDED') {
|
|
61
|
+
laneLandeds[tag] = timestamp;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (verb === 'AUDIT-RED') {
|
|
65
|
+
const mentionedLanes = gist.match(/T\d+(?:-[A-Z0-9-]+)?/g) || [];
|
|
66
|
+
mentionedLanes.forEach(targetLane => {
|
|
67
|
+
// Ensure the mentioned lane also exists in result.lanes
|
|
68
|
+
if (!result.lanes[targetLane]) {
|
|
69
|
+
result.lanes[targetLane] = {
|
|
70
|
+
last_post: null,
|
|
71
|
+
open_reds_against_me: [],
|
|
72
|
+
landed_since_last_red: false
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
openReds.push({ tag: targetLane, timestamp, gist });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (verb === 'FINAL-VERDICT') {
|
|
80
|
+
result.last_final_verdict = {
|
|
81
|
+
verb,
|
|
82
|
+
timestamp: `${date}T${time}:00-04:00`,
|
|
83
|
+
gist
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Calculate landed_since_last_red and open_reds_against_me
|
|
89
|
+
Object.keys(result.lanes).forEach(tag => {
|
|
90
|
+
const lastLanded = laneLandeds[tag];
|
|
91
|
+
|
|
92
|
+
result.lanes[tag].open_reds_against_me = openReds
|
|
93
|
+
.filter(red => red.tag === tag && (!lastLanded || lastLanded <= red.timestamp))
|
|
94
|
+
.map(red => ({ timestamp: red.timestamp, gist: red.gist }));
|
|
95
|
+
|
|
96
|
+
const allRedsForLane = openReds.filter(red => red.tag === tag);
|
|
97
|
+
if (allRedsForLane.length === 0) {
|
|
98
|
+
result.lanes[tag].landed_since_last_red = !!lastLanded;
|
|
99
|
+
} else {
|
|
100
|
+
const latestRedTs = allRedsForLane.reduce((max, r) => r.timestamp > max ? r.timestamp : max, "");
|
|
101
|
+
result.lanes[tag].landed_since_last_red = !!(lastLanded && lastLanded > latestRedTs);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Count open reds
|
|
106
|
+
const uniqueOpenReds = new Set();
|
|
107
|
+
openReds.forEach(red => {
|
|
108
|
+
const lastLanded = laneLandeds[red.tag];
|
|
109
|
+
if (!lastLanded || lastLanded <= red.timestamp) {
|
|
110
|
+
uniqueOpenReds.add(`${red.tag}:${red.timestamp}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
result.open_red_count = uniqueOpenReds.size;
|
|
114
|
+
|
|
115
|
+
// Final Verdict lanes_with_open_defects
|
|
116
|
+
if (result.last_final_verdict) {
|
|
117
|
+
const openDefects = [];
|
|
118
|
+
Object.keys(result.lanes).forEach(tag => {
|
|
119
|
+
if (result.lanes[tag].open_reds_against_me.length > 0) {
|
|
120
|
+
openDefects.push(tag);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
result.last_final_verdict.lanes_with_open_defects = openDefects;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { parseStatusMd };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Sprint 69 T1 — boot-prompt template engine.
|
|
4
|
+
//
|
|
5
|
+
// Looks up templates by (cliType × role), substitutes {{variable}} tokens,
|
|
6
|
+
// and returns the paste-ready string. Default templates ship in
|
|
7
|
+
// packages/server/share/termdeck/templates/. A per-file override at
|
|
8
|
+
// ~/.termdeck/templates/<cli>-<role>.txt wins when present so projects can
|
|
9
|
+
// customise without forking the package.
|
|
10
|
+
//
|
|
11
|
+
// Public contract (consumed by T2's POST /api/sprints/inject handler):
|
|
12
|
+
// loadTemplate(cliType, role, variables) -> string
|
|
13
|
+
// Throws TemplateNotFoundError if neither override nor default exists.
|
|
14
|
+
// Throws MissingVariableError if any {{var}} would be left unsubstituted;
|
|
15
|
+
// the error's `missingVariables` array names every unfilled token (not
|
|
16
|
+
// just the first) so callers can validate the whole template in one pass.
|
|
17
|
+
// requiredVariables(cliType, role) -> string[]
|
|
18
|
+
// Pre-scans a template for {{var}} tokens; lets the inject endpoint
|
|
19
|
+
// validate the request body before rendering.
|
|
20
|
+
//
|
|
21
|
+
// Token grammar: /\{\{(\w+)\}\}/g — flat names, no whitespace, no dotted
|
|
22
|
+
// paths. Deliberately simpler than the Sprint 47 boot-prompt-resolver (which
|
|
23
|
+
// supported `{{lane.tag}}`) so the inject body shape stays flat and the
|
|
24
|
+
// failure mode (unsubstituted token left in the rendered output) is easy to
|
|
25
|
+
// reason about.
|
|
26
|
+
//
|
|
27
|
+
// Override-directory resolution order:
|
|
28
|
+
// 1. process.env.TERMDECK_TEMPLATES_OVERRIDE_DIR (set in tests; also usable
|
|
29
|
+
// for system-wide installs e.g. /etc/termdeck/templates/).
|
|
30
|
+
// 2. ~/.termdeck/templates/ — the documented per-user override location.
|
|
31
|
+
// 3. <repo>/packages/server/share/termdeck/templates/ — the in-package
|
|
32
|
+
// defaults that ship with @jhizzard/termdeck.
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
const SUPPORTED_CLI_TYPES = Object.freeze(['claude-code', 'codex', 'gemini', 'grok']);
|
|
39
|
+
const SUPPORTED_ROLES = Object.freeze(['worker', 'auditor', 'orchestrator']);
|
|
40
|
+
|
|
41
|
+
// Filename convention: `<cliType>-<role>.txt`. `cliType` is the literal
|
|
42
|
+
// session-manager `meta.type` value (verified at session.js:165 — the
|
|
43
|
+
// canonical types are 'shell' / 'claude-code' / 'gemini' / 'python-server' /
|
|
44
|
+
// 'one-shot'). No aliasing layer — per the [ORCH] RULING 2026-05-20 13:13 ET,
|
|
45
|
+
// `meta.type` flows from `GET /api/sessions` straight into `loadTemplate` so
|
|
46
|
+
// T2's inject endpoint stays a one-liner. If the inject caller needs to
|
|
47
|
+
// normalize a short-form name (e.g. 'claude') to 'claude-code', that's the
|
|
48
|
+
// caller's normalization layer (see T2's `normalizeCliType` in
|
|
49
|
+
// packages/server/src/sprints/inject.js).
|
|
50
|
+
|
|
51
|
+
// Templates live alongside the server package: packages/server/share/termdeck/templates/.
|
|
52
|
+
// __dirname here is packages/server/src/templates, so '..' twice lands at
|
|
53
|
+
// packages/server/, then into share/termdeck/templates.
|
|
54
|
+
const DEFAULT_TEMPLATE_DIR = path.join(
|
|
55
|
+
__dirname,
|
|
56
|
+
'..',
|
|
57
|
+
'..',
|
|
58
|
+
'share',
|
|
59
|
+
'termdeck',
|
|
60
|
+
'templates'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
class TemplateNotFoundError extends Error {
|
|
64
|
+
constructor(message, { cliType, role, lookedUpPaths } = {}) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'TemplateNotFoundError';
|
|
67
|
+
this.cliType = cliType;
|
|
68
|
+
this.role = role;
|
|
69
|
+
this.lookedUpPaths = Array.isArray(lookedUpPaths) ? lookedUpPaths : [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class MissingVariableError extends Error {
|
|
74
|
+
constructor(message, { cliType, role, missingVariables } = {}) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'MissingVariableError';
|
|
77
|
+
this.cliType = cliType;
|
|
78
|
+
this.role = role;
|
|
79
|
+
this.missingVariables = Array.isArray(missingVariables) ? missingVariables : [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _overrideDir() {
|
|
84
|
+
if (process.env.TERMDECK_TEMPLATES_OVERRIDE_DIR) {
|
|
85
|
+
return process.env.TERMDECK_TEMPLATES_OVERRIDE_DIR;
|
|
86
|
+
}
|
|
87
|
+
return path.join(os.homedir(), '.termdeck', 'templates');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _resolveTemplatePath(cliType, role) {
|
|
91
|
+
if (!cliType || typeof cliType !== 'string') {
|
|
92
|
+
throw new TemplateNotFoundError(
|
|
93
|
+
`loadTemplate requires a string cliType; got ${cliType === undefined ? 'undefined' : JSON.stringify(cliType)}`,
|
|
94
|
+
{ cliType, role, lookedUpPaths: [] }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!role || typeof role !== 'string') {
|
|
98
|
+
throw new TemplateNotFoundError(
|
|
99
|
+
`loadTemplate requires a string role; got ${role === undefined ? 'undefined' : JSON.stringify(role)}`,
|
|
100
|
+
{ cliType, role, lookedUpPaths: [] }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!SUPPORTED_CLI_TYPES.includes(cliType)) {
|
|
105
|
+
throw new TemplateNotFoundError(
|
|
106
|
+
`Unknown cliType="${cliType}". Supported: ${SUPPORTED_CLI_TYPES.join(', ')}.`,
|
|
107
|
+
{ cliType, role, lookedUpPaths: [] }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (!SUPPORTED_ROLES.includes(role)) {
|
|
111
|
+
throw new TemplateNotFoundError(
|
|
112
|
+
`Unknown role="${role}". Supported: ${SUPPORTED_ROLES.join(', ')}.`,
|
|
113
|
+
{ cliType, role, lookedUpPaths: [] }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const filename = `${cliType}-${role}.txt`;
|
|
118
|
+
const overrideCandidate = path.join(_overrideDir(), filename);
|
|
119
|
+
const defaultCandidate = path.join(DEFAULT_TEMPLATE_DIR, filename);
|
|
120
|
+
|
|
121
|
+
if (fs.existsSync(overrideCandidate)) {
|
|
122
|
+
return { path: overrideCandidate, source: 'override' };
|
|
123
|
+
}
|
|
124
|
+
if (fs.existsSync(defaultCandidate)) {
|
|
125
|
+
return { path: defaultCandidate, source: 'default' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new TemplateNotFoundError(
|
|
129
|
+
`Template not found for cliType="${cliType}" role="${role}". Looked up override=${overrideCandidate}, default=${defaultCandidate}.`,
|
|
130
|
+
{ cliType, role, lookedUpPaths: [overrideCandidate, defaultCandidate] }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _scanVariables(rawTemplate) {
|
|
135
|
+
const found = new Set();
|
|
136
|
+
// Fresh regex per call — global regexes carry stateful lastIndex.
|
|
137
|
+
const re = /\{\{(\w+)\}\}/g;
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = re.exec(rawTemplate)) !== null) {
|
|
140
|
+
found.add(m[1]);
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadTemplate(cliType, role, variables) {
|
|
146
|
+
const { path: templatePath } = _resolveTemplatePath(cliType, role);
|
|
147
|
+
const raw = fs.readFileSync(templatePath, 'utf8');
|
|
148
|
+
const vars = (variables && typeof variables === 'object') ? variables : {};
|
|
149
|
+
|
|
150
|
+
// First pass: substitute every {{name}} whose name is present (and not
|
|
151
|
+
// null/undefined). Empty string IS a valid substitution. We use a fresh
|
|
152
|
+
// regex per replace call so multiple loadTemplate() calls don't interact.
|
|
153
|
+
const rendered = raw.replace(/\{\{(\w+)\}\}/g, (match, name) => {
|
|
154
|
+
if (
|
|
155
|
+
Object.prototype.hasOwnProperty.call(vars, name) &&
|
|
156
|
+
vars[name] !== undefined &&
|
|
157
|
+
vars[name] !== null
|
|
158
|
+
) {
|
|
159
|
+
return String(vars[name]);
|
|
160
|
+
}
|
|
161
|
+
return match;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Second pass: detect any {{...}} still in the rendered string. If any
|
|
165
|
+
// remain, the contract is violated — name them all so the caller fixes
|
|
166
|
+
// them in one pass instead of one-at-a-time.
|
|
167
|
+
const leftover = _scanVariables(rendered);
|
|
168
|
+
if (leftover.size > 0) {
|
|
169
|
+
const missing = Array.from(leftover).sort();
|
|
170
|
+
throw new MissingVariableError(
|
|
171
|
+
`Missing template variables for ${cliType}/${role}: ${missing.join(', ')}`,
|
|
172
|
+
{ cliType, role, missingVariables: missing }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return rendered;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function requiredVariables(cliType, role) {
|
|
180
|
+
const { path: templatePath } = _resolveTemplatePath(cliType, role);
|
|
181
|
+
const raw = fs.readFileSync(templatePath, 'utf8');
|
|
182
|
+
return Array.from(_scanVariables(raw)).sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
loadTemplate,
|
|
187
|
+
requiredVariables,
|
|
188
|
+
TemplateNotFoundError,
|
|
189
|
+
MissingVariableError,
|
|
190
|
+
SUPPORTED_CLI_TYPES,
|
|
191
|
+
SUPPORTED_ROLES,
|
|
192
|
+
DEFAULT_TEMPLATE_DIR,
|
|
193
|
+
// Internal helpers exported for unit tests; not part of the public contract.
|
|
194
|
+
_resolveTemplatePath,
|
|
195
|
+
_overrideDir,
|
|
196
|
+
_scanVariables,
|
|
197
|
+
};
|