@jhizzard/termdeck 0.15.0 → 0.16.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/docs/multi-agent-substrate/boot-prompts/boot-prompt-claude.md +14 -0
- package/docs/multi-agent-substrate/boot-prompts/boot-prompt-codex.md +14 -0
- package/docs/multi-agent-substrate/boot-prompts/boot-prompt-gemini.md +14 -0
- package/docs/multi-agent-substrate/boot-prompts/boot-prompt-grok.md +14 -0
- package/package.json +2 -1
- package/packages/server/src/agent-adapters/claude.js +4 -0
- package/packages/server/src/agent-adapters/codex.js +3 -0
- package/packages/server/src/agent-adapters/gemini.js +3 -0
- package/packages/server/src/agent-adapters/grok.js +16 -4
- package/packages/server/src/boot-prompt-resolver.js +81 -0
- package/packages/server/src/sprint-frontmatter.js +151 -0
- package/packages/server/src/sprint-inject.js +150 -31
- package/packages/server/src/status-merger.js +141 -0
|
@@ -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.
|
|
3
|
+
"version": "0.16.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"
|
|
@@ -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
|
],
|
|
@@ -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
|
|
57
|
-
// only stable string
|
|
58
|
-
//
|
|
59
|
-
|
|
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
|
|
3
|
+
// Two-stage submit pattern for the in-dashboard 4+1 sprint runner.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 <
|
|
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
|
-
|
|
89
|
-
|
|
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 <
|
|
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
|
|
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
|
|
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 =
|
|
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 };
|