@jhizzard/termdeck 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ You are {{lane.tag}} in TermDeck Sprint {{sprint.n}} ({{sprint.name}}). Joshua may be orchestrating from his phone via Telegram (the orchestrator session runs with the @JoshTermDeckBot listener active via claude-tg).
2
+
3
+ Boot sequence:
4
+
5
+ 1. Run `date` to time-stamp.
6
+ 2. memory_recall(project="{{lane.project}}", query="{{lane.topic}}")
7
+ 3. memory_recall(query="recent decisions and bugs across projects")
8
+ 4. Read /Users/joshuaizzard/.claude/CLAUDE.md (global rules — two-stage submit, never copy-paste, session-end email mandate)
9
+ 5. Read /Users/joshuaizzard/Documents/Graciella/ChopinNashville/SideHustles/TermDeck/termdeck/CLAUDE.md (project router)
10
+ 6. Read {{sprint.docPath}}/PLANNING.md
11
+ 7. Read {{sprint.docPath}}/STATUS.md
12
+ 8. Read {{sprint.docPath}}/{{lane.briefing}} (your full briefing — authoritative)
13
+
14
+ Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in {{sprint.docPath}}/STATUS.md (append-only, with timestamps). Don't bump versions, don't touch CHANGELOG, don't commit. Orchestrator handles all close-out.
@@ -0,0 +1,14 @@
1
+ You are {{lane.tag}} in TermDeck Sprint {{sprint.n}} ({{sprint.name}}), running on the Codex CLI. Joshua may be orchestrating from his phone via Telegram (the orchestrator session runs with the @JoshTermDeckBot listener active via claude-tg).
2
+
3
+ Boot sequence:
4
+
5
+ 1. Run `date` to time-stamp.
6
+ 2. memory_recall(project="{{lane.project}}", query="{{lane.topic}}")
7
+ (Mnestra MCP — wired into Codex via ~/.codex/config.toml [mcp_servers.mnestra]. If memory_recall is unavailable, fall back to: cat /Users/joshuaizzard/.claude/projects/-Users-joshuaizzard-Documents-Graciella-ChopinNashville-SideHustles-TermDeck-termdeck/memory/MEMORY.md and grep the relevant terms.)
8
+ 3. memory_recall(query="recent decisions and bugs across projects")
9
+ 4. Read /Users/joshuaizzard/Documents/Graciella/ChopinNashville/SideHustles/TermDeck/termdeck/AGENTS.md (project router — 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)
10
+ 5. Read {{sprint.docPath}}/PLANNING.md
11
+ 6. Read {{sprint.docPath}}/STATUS.md
12
+ 7. Read {{sprint.docPath}}/{{lane.briefing}} (your full briefing — authoritative)
13
+
14
+ Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in {{sprint.docPath}}/STATUS.md (append-only, with timestamps). Use the canonical "Tn: <FINDING|FIX-PROPOSED|DONE> — <one-line summary> — <timestamp>" shape; do not prefix with emoji or wrap in code-fence — the cross-agent STATUS merger normalizes alternate shapes but the canonical form skips that pass. Don't bump versions, don't touch CHANGELOG, don't commit. Orchestrator handles all close-out.
@@ -0,0 +1,14 @@
1
+ You are {{lane.tag}} in TermDeck Sprint {{sprint.n}} ({{sprint.name}}), running on the Gemini CLI. Joshua may be orchestrating from his phone via Telegram (the orchestrator session runs with the @JoshTermDeckBot listener active via claude-tg).
2
+
3
+ Boot sequence:
4
+
5
+ 1. Run `date` to time-stamp.
6
+ 2. memory_recall(project="{{lane.project}}", query="{{lane.topic}}")
7
+ (Mnestra MCP — wired into Gemini via ~/.gemini/settings.json mcpServers.mnestra. If memory_recall is unavailable, fall back to: read /Users/joshuaizzard/.claude/projects/-Users-joshuaizzard-Documents-Graciella-ChopinNashville-SideHustles-TermDeck-termdeck/memory/MEMORY.md and grep the relevant terms.)
8
+ 3. memory_recall(query="recent decisions and bugs across projects")
9
+ 4. Read /Users/joshuaizzard/Documents/Graciella/ChopinNashville/SideHustles/TermDeck/termdeck/GEMINI.md (project router — auto-generated mirror of CLAUDE.md via scripts/sync-agent-instructions.js; canonical content lives in CLAUDE.md but GEMINI.md is what Gemini reads natively)
10
+ 5. Read {{sprint.docPath}}/PLANNING.md
11
+ 6. Read {{sprint.docPath}}/STATUS.md
12
+ 7. Read {{sprint.docPath}}/{{lane.briefing}} (your full briefing — authoritative)
13
+
14
+ Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in {{sprint.docPath}}/STATUS.md (append-only, with timestamps). Use the canonical "Tn: <FINDING|FIX-PROPOSED|DONE> — <one-line summary> — <timestamp>" shape; avoid bullet lists or prose wrapping — the cross-agent STATUS merger normalizes alternate shapes but the canonical form skips that pass. Don't bump versions, don't touch CHANGELOG, don't commit. Orchestrator handles all close-out.
@@ -0,0 +1,14 @@
1
+ You are {{lane.tag}} in TermDeck Sprint {{sprint.n}} ({{sprint.name}}), running on the Grok CLI (SuperGrok Heavy). Joshua may be orchestrating from his phone via Telegram (the orchestrator session runs with the @JoshTermDeckBot listener active via claude-tg).
2
+
3
+ Boot sequence:
4
+
5
+ 1. Run `date` to time-stamp.
6
+ 2. memory_recall(project="{{lane.project}}", query="{{lane.topic}}")
7
+ (Mnestra MCP — wired into Grok via ~/.grok/user-settings.json mcpServers.mnestra. If memory_recall is unavailable, fall back to: read /Users/joshuaizzard/.claude/projects/-Users-joshuaizzard-Documents-Graciella-ChopinNashville-SideHustles-TermDeck-termdeck/memory/MEMORY.md and grep the relevant terms.)
8
+ 3. memory_recall(query="recent decisions and bugs across projects")
9
+ 4. Read /Users/joshuaizzard/Documents/Graciella/ChopinNashville/SideHustles/TermDeck/termdeck/AGENTS.md (project router — auto-generated mirror of CLAUDE.md via scripts/sync-agent-instructions.js; canonical content lives in CLAUDE.md but AGENTS.md is what Grok reads natively, shared with Codex)
10
+ 5. Read {{sprint.docPath}}/PLANNING.md
11
+ 6. Read {{sprint.docPath}}/STATUS.md
12
+ 7. Read {{sprint.docPath}}/{{lane.briefing}} (your full briefing — authoritative)
13
+
14
+ Then begin. Stay in your lane. Post FINDING / FIX-PROPOSED / DONE in {{sprint.docPath}}/STATUS.md (append-only, with timestamps). Use the canonical "Tn: <FINDING|FIX-PROPOSED|DONE> — <one-line summary> — <timestamp>" shape; avoid free-form prose narration — the cross-agent STATUS merger normalizes alternate shapes but the canonical form skips that pass. Don't bump versions, don't touch CHANGELOG, don't commit. Orchestrator handles all close-out.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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"
@@ -15,6 +15,7 @@
15
15
  "config/secrets.env.example",
16
16
  "config/transcript-migration.sql",
17
17
  "docs/orchestrator-guide.md",
18
+ "docs/multi-agent-substrate/boot-prompts/*.md",
18
19
  "LICENSE",
19
20
  "README.md"
20
21
  ],
@@ -361,8 +361,18 @@ async function _runSchemaCheck(opts = {}) {
361
361
  hint: `run: psql "$DATABASE_URL" -f config/transcript-migration.sql`,
362
362
  });
363
363
 
364
- // Rumen — table existence and the created_at column drift Brad hit
364
+ // Rumen — table existence and timestamp column drift detection.
365
+ // Migration 001 defines rumen_jobs.started_at (semantically the tick
366
+ // start time) — NOT created_at. The other two tables use created_at.
367
+ // Pre-0.16.1 doctor probed `created_at` for all three, which produced
368
+ // a false-positive WARN on rumen_jobs and pointed users at a phantom
369
+ // migration drift (Brad, 2026-05-02).
365
370
  const rumen = sections[3].checks;
371
+ const RUMEN_TIME_COL = {
372
+ rumen_jobs: 'started_at',
373
+ rumen_insights: 'created_at',
374
+ rumen_questions: 'created_at',
375
+ };
366
376
  for (const t of ['rumen_jobs', 'rumen_insights', 'rumen_questions']) {
367
377
  const tableOk = await probeSchema(client, SCHEMA_QUERIES.table(t));
368
378
  rumen.push({
@@ -373,9 +383,10 @@ async function _runSchemaCheck(opts = {}) {
373
383
  // Only check the column when the table exists — otherwise the column
374
384
  // line is redundant noise.
375
385
  if (tableOk) {
386
+ const col = RUMEN_TIME_COL[t];
376
387
  rumen.push({
377
- label: `${t}.created_at column`,
378
- status: (await probeSchema(client, SCHEMA_QUERIES.column(t, 'created_at'))) ? 'pass' : 'fail',
388
+ label: `${t}.${col} column`,
389
+ status: (await probeSchema(client, SCHEMA_QUERIES.column(t, col))) ? 'pass' : 'fail',
379
390
  hint: `column drift detected — re-run: termdeck init --rumen`,
380
391
  });
381
392
  }
@@ -153,6 +153,10 @@ const claudeAdapter = {
153
153
  parseTranscript,
154
154
  bootPromptTemplate,
155
155
  costBand: 'pay-per-token',
156
+ // Sprint 47 T3 — Claude Code's input box accepts bracketed-paste cleanly.
157
+ // The two-stage submit pattern (paste then \r alone) is the canonical inject
158
+ // shape for this adapter; chunked-fallback is unnecessary.
159
+ acceptsPaste: true,
156
160
  };
157
161
 
158
162
  module.exports = claudeAdapter;
@@ -194,6 +194,9 @@ const codexAdapter = {
194
194
  parseTranscript,
195
195
  bootPromptTemplate,
196
196
  costBand: 'pay-per-token',
197
+ // Sprint 47 T3 — Codex's Ratatui TUI accepts bracketed-paste per the
198
+ // Sprint 45 T1 audit; safe to use the two-stage submit pattern unchanged.
199
+ acceptsPaste: true,
197
200
  };
198
201
 
199
202
  module.exports = codexAdapter;
@@ -153,6 +153,9 @@ const geminiAdapter = {
153
153
  parseTranscript,
154
154
  bootPromptTemplate,
155
155
  costBand: 'pay-per-token',
156
+ // Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
157
+ // session shape captured in Sprint 45 T2; bracketed-paste injects cleanly.
158
+ acceptsPaste: true,
156
159
  };
157
160
 
158
161
  module.exports = geminiAdapter;
@@ -53,10 +53,16 @@ const { chooseModel } = require('./grok-models');
53
53
  // than false positives (badge flapping or spurious 'errored' status).
54
54
  // ──────────────────────────────────────────────────────────────────────────
55
55
 
56
- // Prompt indicator — the TUI's empty-state placeholder. Weak signal but the
57
- // only stable string that appears reliably in TUI output. Sprint 46 T4 may
58
- // refine if a more precise marker is observed.
59
- const PROMPT = /Message Grok[….]/;
56
+ // Prompt indicator — Sprint 45 T3 anchored on the empty-state placeholder
57
+ // "Message Grok…" assuming it was the only stable string in TUI output.
58
+ // That assumption was wrong: the TUI rotates placeholders ("What are we
59
+ // building?", "Bring me a problem", etc.). Sprint 47 orchestrator side-task
60
+ // extends to also match the model-mode footer line ("Grok 4.20 Reasoning",
61
+ // "Grok 4.20 Heavy", "Grok 4.20 Code", "Grok 4.20 Auto", "Grok 4.20
62
+ // Planning") which renders on every frame regardless of which placeholder
63
+ // the TUI surfaced. Version number digits stay open-ended so future Grok
64
+ // versions don't regress detection.
65
+ const PROMPT = /Message Grok[….]|Grok\s+\d+(?:\.\d+)?\s+(?:Reasoning|Heavy|Code|Auto|Planning)/;
60
66
 
61
67
  // Thinking — Grok's three known "isProcessing" shimmer states. Hits any of
62
68
  // the literal labels. The trailing variants on "Generating" / "Answering"
@@ -248,6 +254,12 @@ const grokAdapter = {
248
254
  parseTranscript,
249
255
  bootPromptTemplate,
250
256
  costBand: 'subscription',
257
+ // Sprint 47 T3 — Grok's Bun+OpenTUI input box hasn't been empirically
258
+ // pasted-against yet (Sprint 45 T3 prep notes flagged this for verification).
259
+ // Default to true so the helper uses the bracketed-paste fast path; if a
260
+ // lane-time test shows the OpenTUI input handler eats the paste markers,
261
+ // flip this to false and the inject helper falls back to chunked stdin.
262
+ acceptsPaste: true,
251
263
  };
252
264
 
253
265
  module.exports = grokAdapter;
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Per-agent boot-prompt resolver — Sprint 47 T2.
4
+ //
5
+ // Reads a Mustache-style template from
6
+ // docs/multi-agent-substrate/boot-prompts/boot-prompt-<agent>.md, interpolates
7
+ // {{var.path}} placeholders against the provided `vars` object, and returns
8
+ // the final paste-ready string for the 4+1 inject script.
9
+ //
10
+ // Sprint 47 T3 wires the inject helper to read `lane.agent` from the lane
11
+ // definition and call resolveBootPrompt(agent, vars) per lane — that's how
12
+ // mixed 4+1 (Sprint 48+) gets agent-correct boot prompts when a sprint
13
+ // declares e.g. T1=codex / T2=gemini / T3=grok / T4=claude.
14
+ //
15
+ // Contract:
16
+ // resolveBootPrompt(agentName, vars) -> string
17
+ // agentName ∈ {claude, codex, gemini, grok}
18
+ // vars : { lane: {tag, project, topic, briefing}, sprint: {n, name, docPath} }
19
+ // Throws on unknown agent (with the four valid options listed) or any
20
+ // missing placeholder variable (with the dotted path reported, e.g.
21
+ // "Missing variable: lane.tag"). No template-engine dependency — hand-rolled
22
+ // regex interpolation, ~10 LOC. Project is no-build vanilla JS.
23
+ //
24
+ // Pure read-only: re-reads the template file on each call so authors can edit
25
+ // templates without restarting the server. The Sprint-47 templates are <1KB
26
+ // each so the disk hit is negligible.
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const VALID_AGENTS = ['claude', 'codex', 'gemini', 'grok'];
32
+
33
+ // Resolve template directory relative to this file. __dirname is
34
+ // packages/server/src; the templates live at <repo-root>/docs/multi-agent-substrate/boot-prompts.
35
+ const DEFAULT_TEMPLATE_DIR = path.join(
36
+ __dirname, '..', '..', '..',
37
+ 'docs', 'multi-agent-substrate', 'boot-prompts'
38
+ );
39
+
40
+ function _resolveDotted(vars, dotPath) {
41
+ const parts = dotPath.split('.');
42
+ let cur = vars;
43
+ for (const p of parts) {
44
+ if (cur == null || typeof cur !== 'object' || !Object.prototype.hasOwnProperty.call(cur, p)) {
45
+ return undefined;
46
+ }
47
+ cur = cur[p];
48
+ }
49
+ return cur;
50
+ }
51
+
52
+ function _interpolate(template, vars) {
53
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, dotPath) => {
54
+ const value = _resolveDotted(vars, dotPath);
55
+ if (value === undefined || value === null) {
56
+ throw new Error(`Missing variable: ${dotPath}`);
57
+ }
58
+ return String(value);
59
+ });
60
+ }
61
+
62
+ function resolveBootPrompt(agentName, vars, options) {
63
+ if (!VALID_AGENTS.includes(agentName)) {
64
+ throw new Error(
65
+ `Unknown agent: ${agentName}. Valid agents are: ${VALID_AGENTS.join(', ')}`
66
+ );
67
+ }
68
+ const dir = (options && options.templateDir) || DEFAULT_TEMPLATE_DIR;
69
+ const filePath = path.join(dir, `boot-prompt-${agentName}.md`);
70
+ const template = fs.readFileSync(filePath, 'utf8');
71
+ return _interpolate(template, vars || {});
72
+ }
73
+
74
+ module.exports = {
75
+ resolveBootPrompt,
76
+ VALID_AGENTS,
77
+ DEFAULT_TEMPLATE_DIR,
78
+ // Exported for unit tests; not part of the public contract.
79
+ _interpolate,
80
+ _resolveDotted,
81
+ };
@@ -0,0 +1,151 @@
1
+ // Sprint 47 T1 — YAML-subset frontmatter parser for sprint PLANNING.md and
2
+ // per-lane briefs, plus a lane→adapter resolver.
3
+ //
4
+ // Convention this lane establishes (Sprint 48+ uses it):
5
+ // ---
6
+ // sprint: 48
7
+ // lanes:
8
+ // - tag: T1
9
+ // agent: codex
10
+ // - tag: T2
11
+ // agent: gemini
12
+ // ---
13
+ //
14
+ // Sprint 45/46/47 docs have no frontmatter — `parseFrontmatter` returns `{}`
15
+ // and `getLaneAgent` falls back to the Claude adapter. Forward-only, no
16
+ // rewriting of historical PLANNING.md files. No third-party YAML dependency:
17
+ // hand-rolled subset (top-level scalars + a sequence of string-scalar
18
+ // mappings) keeps the no-build vanilla-JS architecture intact.
19
+
20
+ const fs = require('fs');
21
+ const { AGENT_ADAPTERS } = require('./agent-adapters');
22
+
23
+ const VALID_AGENTS = Object.keys(AGENT_ADAPTERS);
24
+
25
+ function parseFrontmatter(filePath) {
26
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
27
+ if (!lines.length || lines[0].trim() !== '---') return {};
28
+ let end = -1;
29
+ for (let i = 1; i < lines.length; i++) {
30
+ if (lines[i].trim() === '---') { end = i; break; }
31
+ }
32
+ if (end === -1) {
33
+ throw new Error(`${filePath}: unclosed frontmatter block (no trailing '---')`);
34
+ }
35
+ const result = {};
36
+ let i = 1;
37
+ while (i < end) {
38
+ const line = lines[i];
39
+ if (line.trim() === '' || line.trim().startsWith('#')) { i++; continue; }
40
+ if (_indent(line, filePath, i) !== 0) {
41
+ throw new Error(`${filePath}:${i + 1}: top-level key must start at column 0`);
42
+ }
43
+ const colon = line.indexOf(':');
44
+ if (colon === -1) {
45
+ throw new Error(`${filePath}:${i + 1}: expected 'key: value' or 'key:'`);
46
+ }
47
+ const key = line.slice(0, colon).trim();
48
+ const rest = line.slice(colon + 1).trim();
49
+ if (rest !== '') {
50
+ result[key] = _scalar(rest, filePath, i);
51
+ i++;
52
+ } else {
53
+ const [items, consumed] = _sequence(lines, i + 1, end, filePath);
54
+ result[key] = items;
55
+ i = i + 1 + consumed;
56
+ }
57
+ }
58
+ if (Array.isArray(result.lanes)) {
59
+ for (const lane of result.lanes) {
60
+ if (lane.agent !== undefined && !VALID_AGENTS.includes(lane.agent)) {
61
+ throw new Error(
62
+ `${filePath}: invalid agent '${lane.agent}' on lane ${lane.tag || '(unknown)'}. ` +
63
+ `Valid: ${VALID_AGENTS.join(', ')}`
64
+ );
65
+ }
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function _sequence(lines, start, end, filePath) {
72
+ const items = [];
73
+ let baseIndent = null;
74
+ let mapIndent = null;
75
+ let current = null;
76
+ let i = start;
77
+ while (i < end) {
78
+ const line = lines[i];
79
+ if (line.trim() === '' || line.trim().startsWith('#')) { i++; continue; }
80
+ const ind = _indent(line, filePath, i);
81
+ if (baseIndent === null) baseIndent = ind;
82
+ if (ind < baseIndent) break;
83
+ const trimmed = line.slice(ind);
84
+ if (ind === baseIndent && trimmed.startsWith('- ')) {
85
+ if (current) items.push(current);
86
+ current = {};
87
+ mapIndent = ind + 2;
88
+ const after = trimmed.slice(2);
89
+ const c = after.indexOf(':');
90
+ if (c === -1) {
91
+ throw new Error(`${filePath}:${i + 1}: sequence item must start with 'key: value'`);
92
+ }
93
+ current[after.slice(0, c).trim()] = _scalar(after.slice(c + 1).trim(), filePath, i);
94
+ } else if (ind === mapIndent && current) {
95
+ const c = line.indexOf(':');
96
+ if (c === -1) {
97
+ throw new Error(`${filePath}:${i + 1}: mapping continuation must be 'key: value'`);
98
+ }
99
+ current[line.slice(0, c).trim()] = _scalar(line.slice(c + 1).trim(), filePath, i);
100
+ } else {
101
+ throw new Error(
102
+ `${filePath}:${i + 1}: unexpected indentation (got ${ind} spaces; ` +
103
+ `expected ${baseIndent} for new item or ${mapIndent} for continuation)`
104
+ );
105
+ }
106
+ i++;
107
+ }
108
+ if (current) items.push(current);
109
+ return [items, i - start];
110
+ }
111
+
112
+ function _scalar(s, filePath, idx) {
113
+ if (s === '') return null;
114
+ const q = s[0];
115
+ if (q === '"' || q === "'") {
116
+ if (s.length < 2 || s[s.length - 1] !== q) {
117
+ throw new Error(`${filePath}:${idx + 1}: unclosed quote`);
118
+ }
119
+ return s.slice(1, -1);
120
+ }
121
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
122
+ if (s === 'true') return true;
123
+ if (s === 'false') return false;
124
+ return s;
125
+ }
126
+
127
+ function _indent(line, filePath, idx) {
128
+ let n = 0;
129
+ while (n < line.length && line[n] === ' ') n++;
130
+ if (line[n] === '\t') {
131
+ throw new Error(`${filePath}:${idx + 1}: tab indentation not supported (use spaces)`);
132
+ }
133
+ return n;
134
+ }
135
+
136
+ function getLaneAgent(briefPath, laneTag) {
137
+ const fm = parseFrontmatter(briefPath);
138
+ if (!Array.isArray(fm.lanes)) return AGENT_ADAPTERS.claude;
139
+ const lane = fm.lanes.find((l) => l.tag === laneTag);
140
+ if (!lane || !lane.agent) return AGENT_ADAPTERS.claude;
141
+ const adapter = AGENT_ADAPTERS[lane.agent];
142
+ if (!adapter) {
143
+ throw new Error(
144
+ `sprint-frontmatter: unknown agent '${lane.agent}' for lane ${laneTag} ` +
145
+ `(valid: ${VALID_AGENTS.join(', ')})`
146
+ );
147
+ }
148
+ return adapter;
149
+ }
150
+
151
+ module.exports = { parseFrontmatter, getLaneAgent };
@@ -1,8 +1,20 @@
1
1
  'use strict';
2
2
 
3
- // Two-stage submit pattern for the in-dashboard 4+1 sprint runner (Sprint 37 T4).
3
+ // Two-stage submit pattern for the in-dashboard 4+1 sprint runner.
4
4
  //
5
- // The cardinal rule from the global 4+1 inject mandate:
5
+ // Sprint 37 T4 baseline: Claude-only, bracketed-paste + lone-CR.
6
+ // Sprint 47 T3 extension: per-lane agent dispatch via the adapter registry.
7
+ // Each lane may declare an `agent` name (claude/codex/gemini/grok). The
8
+ // helper looks up the adapter and selects the inject shape:
9
+ // • acceptsPaste: true → bracketed-paste payload (`\x1b[200~…\x1b[201~`)
10
+ // followed by a lone `\r` after the settle window.
11
+ // • acceptsPaste: false → chunked stdin fallback (line + `\r` per chunk
12
+ // with `chunkedDelayMs` between). Chunked lanes
13
+ // self-submit on their last line; the stage-2
14
+ // `\r` is skipped for them so we don't fire a
15
+ // duplicate empty submit.
16
+ //
17
+ // The cardinal rule from the global 4+1 inject mandate (paste path):
6
18
  //
7
19
  // Stage 1: write `\x1b[200~<prompt>\x1b[201~` to each session in turn,
8
20
  // with a small inter-session gap. NO trailing CR.
@@ -23,24 +35,65 @@
23
35
  // Pure logic — caller injects writeBytes/getStatus/sleep so tests don't need
24
36
  // a live PTY. Wired in by sprint-routes.js.
25
37
 
38
+ const { AGENT_ADAPTERS } = require('./agent-adapters');
39
+
26
40
  const DEFAULTS = {
27
41
  gapMs: 250,
28
42
  settleMs: 400,
29
43
  verifyTimeoutMs: 8000,
30
44
  verifyPollMs: 500,
31
45
  postPokeWaitMs: 500,
46
+ chunkedDelayMs: 20,
32
47
  };
33
48
 
34
- async function injectSprintPrompts({
35
- sessionIds,
36
- prompts,
37
- writeBytes,
38
- getStatus,
39
- sleep,
40
- options,
41
- }) {
49
+ // Resolve the per-lane inject shape from the adapter registry.
50
+ // Returns a discriminated payload:
51
+ // { kind: 'paste', bytes: string }
52
+ // { kind: 'chunked', lines: string[] }
53
+ // When `agent` is null/undefined or the adapter is absent from the registry,
54
+ // defaults to bracketed-paste (the Sprint 37 baseline). Unknown agent names
55
+ // are not an error here — they just fall through to the bracketed-paste path
56
+ // so a typo in a lane brief degrades to "works for Claude-shaped TUIs"
57
+ // rather than throwing mid-inject. Validation belongs at lane-frontmatter
58
+ // parse time (Sprint 47 T1).
59
+ function buildPayload(prompt, agent, adapters) {
60
+ const registry = adapters || AGENT_ADAPTERS;
61
+ const adapter = agent ? registry[agent] : null;
62
+ const acceptsPaste = adapter ? adapter.acceptsPaste !== false : true;
63
+ if (acceptsPaste) {
64
+ return { kind: 'paste', bytes: `\x1b[200~${prompt}\x1b[201~` };
65
+ }
66
+ return { kind: 'chunked', lines: prompt.split('\n') };
67
+ }
68
+
69
+ // Normalize the three accepted input shapes into a single internal lane list.
70
+ // 1. { sessionIds, prompts } — Sprint 37 baseline (claude only)
71
+ // 2. { sessionIds, prompts, agents } — parallel agents array
72
+ // 3. { lanes: [{ sessionId, prompt, agent }] } — Sprint 47 lanes shape
73
+ function normalizeLanes({ sessionIds, prompts, agents, lanes }) {
74
+ if (Array.isArray(lanes)) {
75
+ if (lanes.length === 0) {
76
+ throw new Error('at least one lane required');
77
+ }
78
+ return lanes.map((l, i) => {
79
+ if (!l || typeof l !== 'object') {
80
+ throw new Error(`lanes[${i}] must be an object`);
81
+ }
82
+ if (typeof l.sessionId !== 'string' || !l.sessionId) {
83
+ throw new Error(`lanes[${i}].sessionId must be a non-empty string`);
84
+ }
85
+ if (typeof l.prompt !== 'string') {
86
+ throw new Error(`lanes[${i}].prompt must be a string`);
87
+ }
88
+ return {
89
+ sessionId: l.sessionId,
90
+ prompt: l.prompt,
91
+ agent: l.agent || null,
92
+ };
93
+ });
94
+ }
42
95
  if (!Array.isArray(sessionIds) || !Array.isArray(prompts)) {
43
- throw new Error('sessionIds and prompts must be arrays');
96
+ throw new Error('sessionIds and prompts must be arrays (or pass lanes[])');
44
97
  }
45
98
  if (sessionIds.length !== prompts.length) {
46
99
  throw new Error('sessionIds and prompts must be the same length');
@@ -48,6 +101,28 @@ async function injectSprintPrompts({
48
101
  if (sessionIds.length === 0) {
49
102
  throw new Error('at least one session required');
50
103
  }
104
+ if (agents !== undefined && agents !== null) {
105
+ if (!Array.isArray(agents) || agents.length !== sessionIds.length) {
106
+ throw new Error('agents must be an array of the same length as sessionIds');
107
+ }
108
+ }
109
+ return sessionIds.map((sessionId, i) => ({
110
+ sessionId,
111
+ prompt: prompts[i],
112
+ agent: agents ? agents[i] || null : null,
113
+ }));
114
+ }
115
+
116
+ async function injectSprintPrompts({
117
+ sessionIds,
118
+ prompts,
119
+ agents,
120
+ lanes,
121
+ writeBytes,
122
+ getStatus,
123
+ sleep,
124
+ options,
125
+ }) {
51
126
  if (typeof writeBytes !== 'function') {
52
127
  throw new Error('writeBytes(sessionId, bytes) callback required');
53
128
  }
@@ -56,10 +131,15 @@ async function injectSprintPrompts({
56
131
  }
57
132
 
58
133
  const opts = { ...DEFAULTS, ...(options || {}) };
134
+ const registry = (options && options.adapters) || AGENT_ADAPTERS;
59
135
 
60
- const lanes = sessionIds.map((sessionId, i) => ({
61
- sessionId,
62
- prompt: prompts[i],
136
+ const internal = normalizeLanes({ sessionIds, prompts, agents, lanes });
137
+
138
+ const enriched = internal.map((l) => ({
139
+ sessionId: l.sessionId,
140
+ prompt: l.prompt,
141
+ agent: l.agent,
142
+ dispatch: buildPayload(l.prompt, l.agent, registry),
63
143
  paste: null,
64
144
  submit: null,
65
145
  verified: false,
@@ -67,17 +147,48 @@ async function injectSprintPrompts({
67
147
  finalStatus: null,
68
148
  }));
69
149
 
70
- // Stage 1: paste-only across all lanes, gapMs between each.
71
- for (let i = 0; i < lanes.length; i++) {
72
- const lane = lanes[i];
73
- const payload = `\x1b[200~${lane.prompt}\x1b[201~`;
74
- try {
75
- const r = await writeBytes(lane.sessionId, payload);
76
- lane.paste = { ok: true, bytes: (r && r.bytes) || payload.length };
77
- } catch (err) {
78
- lane.paste = { ok: false, error: err && err.message ? err.message : String(err) };
150
+ // Stage 1: per-lane payload. Paste lanes get one PTY write; chunked lanes
151
+ // get N writes (one per line) with `chunkedDelayMs` between. `gapMs`
152
+ // separates lanes from each other so stages stay deterministically ordered.
153
+ for (let i = 0; i < enriched.length; i++) {
154
+ const lane = enriched[i];
155
+ if (lane.dispatch.kind === 'paste') {
156
+ try {
157
+ const r = await writeBytes(lane.sessionId, lane.dispatch.bytes);
158
+ lane.paste = {
159
+ ok: true,
160
+ bytes: (r && r.bytes) || lane.dispatch.bytes.length,
161
+ mode: 'paste',
162
+ };
163
+ } catch (err) {
164
+ lane.paste = {
165
+ ok: false,
166
+ error: err && err.message ? err.message : String(err),
167
+ mode: 'paste',
168
+ };
169
+ }
170
+ } else {
171
+ // chunked
172
+ let totalBytes = 0;
173
+ let firstError = null;
174
+ for (let j = 0; j < lane.dispatch.lines.length; j++) {
175
+ const chunk = lane.dispatch.lines[j] + '\r';
176
+ try {
177
+ const r = await writeBytes(lane.sessionId, chunk);
178
+ totalBytes += (r && r.bytes) || chunk.length;
179
+ } catch (err) {
180
+ firstError = err && err.message ? err.message : String(err);
181
+ break;
182
+ }
183
+ if (j < lane.dispatch.lines.length - 1) {
184
+ await sleep(opts.chunkedDelayMs);
185
+ }
186
+ }
187
+ lane.paste = firstError
188
+ ? { ok: false, error: firstError, mode: 'chunked' }
189
+ : { ok: true, bytes: totalBytes, mode: 'chunked' };
79
190
  }
80
- if (i < lanes.length - 1) await sleep(opts.gapMs);
191
+ if (i < enriched.length - 1) await sleep(opts.gapMs);
81
192
  }
82
193
 
83
194
  // Settle window — long enough for the PTY to flush each paste to the TUI's
@@ -85,19 +196,25 @@ async function injectSprintPrompts({
85
196
  await sleep(opts.settleMs);
86
197
 
87
198
  // Stage 2: submit-only (\r alone, guaranteed its own PTY write).
88
- for (let i = 0; i < lanes.length; i++) {
89
- const lane = lanes[i];
199
+ // Chunked-mode lanes already self-submitted on their last line — skip.
200
+ for (let i = 0; i < enriched.length; i++) {
201
+ const lane = enriched[i];
90
202
  if (!lane.paste || !lane.paste.ok) {
91
203
  lane.submit = { ok: false, skipped: 'paste-failed' };
92
204
  continue;
93
205
  }
206
+ if (lane.paste.mode === 'chunked') {
207
+ lane.submit = { ok: true, bytes: 0, skipped: 'chunked-already-submitted' };
208
+ if (i < enriched.length - 1) await sleep(opts.gapMs);
209
+ continue;
210
+ }
94
211
  try {
95
212
  const r = await writeBytes(lane.sessionId, '\r');
96
213
  lane.submit = { ok: true, bytes: (r && r.bytes) || 1 };
97
214
  } catch (err) {
98
215
  lane.submit = { ok: false, error: err && err.message ? err.message : String(err) };
99
216
  }
100
- if (i < lanes.length - 1) await sleep(opts.gapMs);
217
+ if (i < enriched.length - 1) await sleep(opts.gapMs);
101
218
  }
102
219
 
103
220
  // Verify: poll each lane's status until it reads `thinking` or we hit the
@@ -106,7 +223,7 @@ async function injectSprintPrompts({
106
223
  const deadline = Date.now() + opts.verifyTimeoutMs;
107
224
  while (Date.now() < deadline) {
108
225
  let anyPending = false;
109
- for (const lane of lanes) {
226
+ for (const lane of enriched) {
110
227
  if (lane.verified) continue;
111
228
  try {
112
229
  const s = await getStatus(lane.sessionId);
@@ -126,7 +243,7 @@ async function injectSprintPrompts({
126
243
 
127
244
  // Auto-poke (cr-flood) any lane that didn't reach `thinking`. Best-effort —
128
245
  // never page the user; the orchestrator dashboard surfaces the result.
129
- for (const lane of lanes) {
246
+ for (const lane of enriched) {
130
247
  if (lane.verified) continue;
131
248
  try {
132
249
  await writeBytes(lane.sessionId, '\r\r\r');
@@ -146,11 +263,13 @@ async function injectSprintPrompts({
146
263
  }
147
264
  }
148
265
 
149
- const ok = lanes.every((l) => l.paste && l.paste.ok && l.submit && l.submit.ok);
150
- return { ok, lanes };
266
+ const ok = enriched.every((l) => l.paste && l.paste.ok && l.submit && l.submit.ok);
267
+ return { ok, lanes: enriched };
151
268
  }
152
269
 
153
270
  module.exports = {
154
271
  injectSprintPrompts,
272
+ buildPayload,
273
+ normalizeLanes,
155
274
  DEFAULTS,
156
275
  };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ // Sprint 47 T4 — Cross-agent STATUS.md merger.
4
+ //
5
+ // Each lane agent (Claude / Codex / Gemini / Grok) posts FINDING /
6
+ // FIX-PROPOSED / DONE differently. Claude has the canonical shape nailed:
7
+ // `- Tn: STAGE — one-line summary — YYYY-MM-DD HH:MM ET`
8
+ // The others may emit emoji prefixes, generic bullet lists, or free-form
9
+ // prose. mergeStatusLine() takes a raw line in any of those shapes and
10
+ // returns the canonical form, so the dashboard's STATUS.md regex parser and
11
+ // human readers see one consistent shape regardless of CLI.
12
+ //
13
+ // Sprint 47 ships this as infrastructure. Sprint 48 (or whenever the mixed
14
+ // 4+1 dogfood lands) is when it actually runs over real cross-agent posts.
15
+
16
+ const STAGES = new Set(['FINDING', 'FIX-PROPOSED', 'DONE']);
17
+
18
+ // Codex idiom: emoji prefix, optionally followed by `Found:`/`Fixed:`/etc.
19
+ // 🛠️ (with VS-16) and 🛠 (without) both round to FIX-PROPOSED — terminals
20
+ // disagree about the variation selector and we don't care which one came in.
21
+ const EMOJI_STAGES = [
22
+ ['🛠️', 'FIX-PROPOSED'],
23
+ ['🛠', 'FIX-PROPOSED'],
24
+ ['🔍', 'FINDING'],
25
+ ['✅', 'DONE'],
26
+ ['🔧', 'FIX-PROPOSED'],
27
+ ];
28
+ const EMOJI_LEADIN_RE = /^(?:Found|Fixed|Proposed|Proposing|Note|Status)\s*[:—\-]?\s*/i;
29
+
30
+ // Gemini idiom: bullet-pointed list with stage keyword.
31
+ const BULLET_FINDING_RE = /^[-*]\s+(?:found(?:\s+that)?|finding|noticed|observation)\s*[:—\-]?\s*(.+)$/i;
32
+ const BULLET_FIX_RE = /^[-*]\s+(?:propos(?:ing|ed)\s+fix(?:ing)?|fix[\s-]proposed|proposed\s+fix)\s*[:—\-]?\s*(.+)$/i;
33
+ const BULLET_DONE_RE = /^[-*]\s+(?:done|completed|finished|shipped)\s*[:—\-]?\s*(.+)$/i;
34
+
35
+ // Grok idiom: free-form first-person prose.
36
+ const PROSE_DONE_RE = /^(?:Done|Completed|Finished|Shipped)\s*[:—\-]?\s*(.+)$/i;
37
+ const PROSE_FIX_RE = /^(?:I'?ll\s+fix|I\s+will\s+fix|I'?m\s+fixing|Fixing|Proposing\s+(?:a\s+)?fix|Proposed\s+fix)\b\s*(?:this\s+by\s+|by\s+|[:—\-]\s*)?(.+)$/i;
38
+ const PROSE_FINDING_RE = /^(?:I\s+noticed|I\s+observed|I\s+found|I\s+saw|Noticed|Observed)\b\s*(?:that\s+)?(.+)$/i;
39
+
40
+ // Canonical Claude — timestamped. Greedy backtracking: we anchor the
41
+ // timestamp to a YYYY-MM-DD prefix so bodies that contain stray ` — ` (very
42
+ // common in real Sprint 46 lines) split at the right boundary.
43
+ const CANONICAL_TS_RE =
44
+ /^[-*]?\s*(T\d+):\s*(FINDING|FIX-PROPOSED|DONE)\s+[—\-]\s+(.+?)\s+[—\-]\s+(\d{4}-\d{2}-\d{2}\b.*)$/;
45
+ // Canonical Claude without a trailing timestamp — we'll add one.
46
+ const CANONICAL_NO_TS_RE =
47
+ /^[-*]?\s*(T\d+):\s*(FINDING|FIX-PROPOSED|DONE)\s+[—\-]\s+(.+)$/;
48
+
49
+ // Markdown section header — never a status line.
50
+ const HEADER_RE = /^#{1,6}\s/;
51
+ // Bare bracket-like meta lines: `_(no entries yet)_`, `> note`, etc.
52
+ const META_RE = /^[_>(]/;
53
+
54
+ const SUMMARY_MAX = 120;
55
+
56
+ function trimSummary(s) {
57
+ s = String(s).trim().replace(/\s+/g, ' ');
58
+ if (s.length > SUMMARY_MAX) {
59
+ s = s.slice(0, SUMMARY_MAX - 1).replace(/\s+\S*$/, '') + '…';
60
+ }
61
+ return s;
62
+ }
63
+
64
+ function pad2(n) {
65
+ return String(n).padStart(2, '0');
66
+ }
67
+
68
+ // Matches the real STATUS.md convention `YYYY-MM-DD HH:MM ET`. The ET tag is
69
+ // decorative (Joshua's tz); we don't try to convert from UTC because the
70
+ // surrounding harness already runs in his local clock.
71
+ function formatTimestamp(date) {
72
+ return (
73
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}` +
74
+ ` ${pad2(date.getHours())}:${pad2(date.getMinutes())} ET`
75
+ );
76
+ }
77
+
78
+ function detectEmojiStage(text) {
79
+ for (const [emoji, stage] of EMOJI_STAGES) {
80
+ if (text.startsWith(emoji)) {
81
+ const after = text.slice(emoji.length).replace(/^[\s️]+/, '');
82
+ return { stage, summary: after.replace(EMOJI_LEADIN_RE, '') };
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function detectBulletStage(text) {
89
+ let m;
90
+ if ((m = BULLET_FINDING_RE.exec(text))) return { stage: 'FINDING', summary: m[1] };
91
+ if ((m = BULLET_FIX_RE.exec(text))) return { stage: 'FIX-PROPOSED', summary: m[1] };
92
+ if ((m = BULLET_DONE_RE.exec(text))) return { stage: 'DONE', summary: m[1] };
93
+ return null;
94
+ }
95
+
96
+ function detectProseStage(text) {
97
+ let m;
98
+ // Order matters: PROSE_DONE before PROSE_FIX before PROSE_FINDING because
99
+ // "Done: foo" would otherwise also match nothing later, and "I'll fix" is
100
+ // distinct from "I noticed" so order between them is safe.
101
+ if ((m = PROSE_DONE_RE.exec(text))) return { stage: 'DONE', summary: m[1] };
102
+ if ((m = PROSE_FIX_RE.exec(text))) return { stage: 'FIX-PROPOSED', summary: m[1] };
103
+ if ((m = PROSE_FINDING_RE.exec(text))) return { stage: 'FINDING', summary: m[1] };
104
+ return null;
105
+ }
106
+
107
+ function mergeStatusLine(rawLine, opts = {}) {
108
+ if (typeof rawLine !== 'string') return null;
109
+ const line = rawLine.replace(/\r?\n$/, '').trim();
110
+ if (!line) return null;
111
+ if (HEADER_RE.test(line)) return null;
112
+ if (META_RE.test(line)) return null;
113
+
114
+ // 1. Canonical with timestamp — pass through unchanged, normalize only the
115
+ // leading bullet. The body is never trimmed here: real Sprint 46 lines are
116
+ // routinely well over 120 chars and the brief mandates "same line out".
117
+ let m = CANONICAL_TS_RE.exec(line);
118
+ if (m) {
119
+ const [, tag, stage, summary, ts] = m;
120
+ return `- ${tag}: ${stage} — ${summary.trim()} — ${ts.trim()}`;
121
+ }
122
+ // 2. Canonical without timestamp — synthesize one. Body still untrimmed:
123
+ // the author wrote a canonical-shape line, we trust its length.
124
+ m = CANONICAL_NO_TS_RE.exec(line);
125
+ if (m) {
126
+ const [, tag, stage, summary] = m;
127
+ const ts = formatTimestamp(opts.now || new Date());
128
+ return `- ${tag}: ${stage} — ${summary.trim()} — ${ts}`;
129
+ }
130
+
131
+ // 3. Variant idioms — emoji, bullet, prose.
132
+ const detected =
133
+ detectEmojiStage(line) || detectBulletStage(line) || detectProseStage(line);
134
+ if (!detected || !STAGES.has(detected.stage)) return null;
135
+
136
+ const tag = opts.laneTag || 'T?';
137
+ const ts = formatTimestamp(opts.now || new Date());
138
+ return `- ${tag}: ${detected.stage} — ${trimSummary(detected.summary)} — ${ts}`;
139
+ }
140
+
141
+ module.exports = { mergeStatusLine };