@jhizzard/termdeck 1.4.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.
@@ -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
+ };