iriai-build 0.1.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/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- package/v3/slack-helpers.js +346 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// slack-helpers.js โ Slack-specific helper functions.
|
|
2
|
+
// Markdown conversion, Block Kit builders, media upload.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert standard Markdown to Slack mrkdwn format.
|
|
9
|
+
*/
|
|
10
|
+
export function markdownToMrkdwn(text) {
|
|
11
|
+
if (!text) return text;
|
|
12
|
+
|
|
13
|
+
const codeBlocks = [];
|
|
14
|
+
let result = text.replace(/```[\s\S]*?```/g, (match) => {
|
|
15
|
+
codeBlocks.push(match);
|
|
16
|
+
return `\x00CODE${codeBlocks.length - 1}\x00`;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const inlineCode = [];
|
|
20
|
+
result = result.replace(/`[^`]+`/g, (match) => {
|
|
21
|
+
inlineCode.push(match);
|
|
22
|
+
return `\x00INLINE${inlineCode.length - 1}\x00`;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Headers -> bold
|
|
26
|
+
result = result.replace(/^#{1,6}\s+(.+)$/gm, "*$1*");
|
|
27
|
+
|
|
28
|
+
// Bold: **text** -> *text*
|
|
29
|
+
result = result.replace(/\*\*(.+?)\*\*/g, "*$1*");
|
|
30
|
+
|
|
31
|
+
// Links: [text](url) -> <url|text>
|
|
32
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
|
|
33
|
+
|
|
34
|
+
// Image refs:  -> [img:path]
|
|
35
|
+
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "[img:$2]");
|
|
36
|
+
|
|
37
|
+
// Restore inline code
|
|
38
|
+
result = result.replace(/\x00INLINE(\d+)\x00/g, (_, i) => inlineCode[i]);
|
|
39
|
+
|
|
40
|
+
// Restore code blocks
|
|
41
|
+
result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeBlocks[i]);
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isMediaPathAllowed(filePath) {
|
|
47
|
+
const resolved = path.resolve(filePath);
|
|
48
|
+
const ALLOWED_DIRS = [
|
|
49
|
+
path.resolve("/Users/danielzhang/src/iriai"),
|
|
50
|
+
"/tmp",
|
|
51
|
+
"/private/tmp",
|
|
52
|
+
];
|
|
53
|
+
return ALLOWED_DIRS.some(dir => resolved.startsWith(dir + path.sep) || resolved === dir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Upload a binary file as a Slack attachment.
|
|
58
|
+
*/
|
|
59
|
+
export async function uploadGifAttachment(web, channel, thread_ts, filePath, title) {
|
|
60
|
+
try {
|
|
61
|
+
if (!isMediaPathAllowed(filePath)) {
|
|
62
|
+
console.warn(`[uploadGifAttachment] Path not in allowed directories: ${filePath}`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(filePath)) {
|
|
67
|
+
console.warn(`[bridge] Media file not found: ${filePath}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stats = fs.statSync(filePath);
|
|
72
|
+
if (stats.size > 20 * 1024 * 1024) {
|
|
73
|
+
console.warn(`[bridge] Media too large (${(stats.size / 1024 / 1024).toFixed(1)}MB): ${filePath}`);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
78
|
+
await web.filesUploadV2({
|
|
79
|
+
channel_id: channel,
|
|
80
|
+
thread_ts,
|
|
81
|
+
file: fileBuffer,
|
|
82
|
+
filename: path.basename(filePath),
|
|
83
|
+
title: title || path.basename(filePath),
|
|
84
|
+
});
|
|
85
|
+
return true;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`[bridge] Media upload failed for ${filePath}:`, err.message);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function uploadArtifact(web, channel, thread_ts, filePath, title) {
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
95
|
+
await web.filesUploadV2({
|
|
96
|
+
channel_id: channel,
|
|
97
|
+
thread_ts,
|
|
98
|
+
content,
|
|
99
|
+
filename: path.basename(filePath),
|
|
100
|
+
title,
|
|
101
|
+
});
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`Error uploading artifact ${title}:`, err.message);
|
|
104
|
+
try {
|
|
105
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
106
|
+
const truncated =
|
|
107
|
+
content.length > 3000
|
|
108
|
+
? content.slice(0, 3000) + "\n\n_(truncated -- full document in repo)_"
|
|
109
|
+
: content;
|
|
110
|
+
await postToThread(web, channel, thread_ts, `*${title}:*\n\n${truncated}`);
|
|
111
|
+
} catch (fallbackErr) {
|
|
112
|
+
console.error(`Fallback text post also failed for ${title}:`, fallbackErr.message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Post a message to a Slack thread with mrkdwn conversion.
|
|
119
|
+
*/
|
|
120
|
+
export async function postToThread(web, channel, thread_ts, text) {
|
|
121
|
+
await web.chat.postMessage({ channel, thread_ts, text: markdownToMrkdwn(text), mrkdwn: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build Slack Block Kit blocks for a decision with action buttons.
|
|
126
|
+
*/
|
|
127
|
+
export function buildDecisionBlocks(decisionId, title, context, options) {
|
|
128
|
+
const blocks = [];
|
|
129
|
+
|
|
130
|
+
blocks.push({
|
|
131
|
+
type: "section",
|
|
132
|
+
text: { type: "mrkdwn", text: `*${title}*${context ? `\n${context}` : ""}` },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
blocks.push({
|
|
136
|
+
type: "actions",
|
|
137
|
+
block_id: `decision_${decisionId}`,
|
|
138
|
+
elements: options.map(opt => ({
|
|
139
|
+
type: "button",
|
|
140
|
+
text: { type: "plain_text", text: opt.label, emoji: true },
|
|
141
|
+
value: opt.id,
|
|
142
|
+
action_id: `decision_${decisionId}_${opt.id}`,
|
|
143
|
+
...(opt.style === "primary" ? { style: "primary" } : {}),
|
|
144
|
+
...(opt.style === "danger" ? { style: "danger" } : {}),
|
|
145
|
+
})),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return blocks;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build resolved-state blocks to replace buttons after a decision is made.
|
|
153
|
+
*/
|
|
154
|
+
export function buildResolvedBlocks(title, selectedLabel, resolvedBy, feedback = "") {
|
|
155
|
+
const feedbackLine = feedback ? `\n> ${feedback.replace(/\n/g, "\n> ")}` : "";
|
|
156
|
+
return [{
|
|
157
|
+
type: "section",
|
|
158
|
+
text: {
|
|
159
|
+
type: "mrkdwn",
|
|
160
|
+
text: `*${title}*\n~Resolved~: *${selectedLabel}*${resolvedBy ? ` by <@${resolvedBy}>` : ""}${feedbackLine}`,
|
|
161
|
+
},
|
|
162
|
+
}];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function addReaction(web, channel, timestamp, reaction) {
|
|
166
|
+
try {
|
|
167
|
+
await web.reactions.add({ channel, name: reaction, timestamp });
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore -- reaction may already exist
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function removeReaction(web, channel, timestamp, reaction) {
|
|
174
|
+
try {
|
|
175
|
+
await web.reactions.remove({ channel, name: reaction, timestamp });
|
|
176
|
+
} catch {
|
|
177
|
+
// Ignore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// terminal-adapter.js โ TerminalAdapter implementing InterfaceAdapter.
|
|
2
|
+
// For terminal-based (iriai-build CLI) development workflows.
|
|
3
|
+
//
|
|
4
|
+
// postDecision() routes decisions through the Operator agent. The Operator
|
|
5
|
+
// presents the decision conversationally and resolves it via [RESOLVE_DECISION].
|
|
6
|
+
// This matches Slack's async model โ no blocking prompts on stdin.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import * as queries from "../queries.js";
|
|
12
|
+
import { InterfaceAdapter } from "./interface.js";
|
|
13
|
+
import { ensureDir, parseGifMarkers } from "../helpers.js";
|
|
14
|
+
|
|
15
|
+
export class TerminalAdapter extends InterfaceAdapter {
|
|
16
|
+
constructor({ logDir } = {}) {
|
|
17
|
+
super();
|
|
18
|
+
this.logDir = logDir || path.join(process.env.HOME, ".iriai", "bridge-logs");
|
|
19
|
+
ensureDir(this.logDir);
|
|
20
|
+
this._inputHandler = null;
|
|
21
|
+
this._events = new EventEmitter();
|
|
22
|
+
this._recentArtifacts = []; // tracks artifacts for inclusion in decision blocks
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set the input handler (TerminalInput instance) for interactive input.
|
|
27
|
+
*/
|
|
28
|
+
setInputHandler(handler) {
|
|
29
|
+
this._inputHandler = handler;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Print a line, respecting active prompts (buffer if needed, stop spinner).
|
|
34
|
+
*/
|
|
35
|
+
_print(text) {
|
|
36
|
+
if (this._inputHandler) {
|
|
37
|
+
this._inputHandler.printAboveLine(text);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe to adapter lifecycle events.
|
|
45
|
+
* Events: "plan-approved", "plan-rejected", "feature-complete"
|
|
46
|
+
*/
|
|
47
|
+
on(event, listener) {
|
|
48
|
+
this._events.on(event, listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
removeListener(event, listener) {
|
|
52
|
+
this._events.removeListener(event, listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async createFeatureChannel(featureId, slug) {
|
|
56
|
+
const featureLogDir = path.join(this.logDir, slug);
|
|
57
|
+
ensureDir(featureLogDir);
|
|
58
|
+
return slug; // opaque ref = slug
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async postMessage(featureId, text) {
|
|
62
|
+
const feature = queries.getFeatureById(featureId);
|
|
63
|
+
const slug = feature?.slug || "unknown";
|
|
64
|
+
const C = { reset: "\x1b[0m", dim: "\x1b[2m" };
|
|
65
|
+
this._print(`${C.dim}[${slug}]${C.reset} ${text}`);
|
|
66
|
+
this._appendLog(slug, text);
|
|
67
|
+
return { ref: Date.now().toString() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async postThreadMessage(featureId, text) {
|
|
71
|
+
await this.postMessage(featureId, text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async postPipelineMessage(featureId, text) {
|
|
75
|
+
const feature = queries.getFeatureById(featureId);
|
|
76
|
+
const slug = feature?.slug || "unknown";
|
|
77
|
+
const C = { reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m", blue: "\x1b[34m", white: "\x1b[37m", bgBlue: "\x1b[44m" };
|
|
78
|
+
const msg = `${C.dim}[${slug}]${C.reset} ${C.bgBlue}${C.white} Pipeline ${C.reset} ${text}`;
|
|
79
|
+
this._print(msg);
|
|
80
|
+
this._appendLog(slug, `[Pipeline] ${text}`);
|
|
81
|
+
queries.insertEvent(featureId, "system", "bridge", text);
|
|
82
|
+
return { ref: Date.now().toString() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async postAgentResponse(featureId, agentLabel, rawContent) {
|
|
86
|
+
const feature = queries.getFeatureById(featureId);
|
|
87
|
+
const slug = feature?.slug || "unknown";
|
|
88
|
+
const { text } = parseGifMarkers(rawContent);
|
|
89
|
+
|
|
90
|
+
const C = {
|
|
91
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
92
|
+
cyan: "\x1b[36m", yellow: "\x1b[33m", white: "\x1b[37m",
|
|
93
|
+
magenta: "\x1b[35m", blue: "\x1b[34m", green: "\x1b[32m",
|
|
94
|
+
};
|
|
95
|
+
const labelColor = agentLabel === "Operator" ? C.white : C.cyan;
|
|
96
|
+
|
|
97
|
+
// Colorize inline agent attribution tags like *[PM]* or [PM] in Operator relay text
|
|
98
|
+
const AGENT_TAG_COLORS = {
|
|
99
|
+
PM: C.cyan, Designer: C.magenta, Architect: C.blue,
|
|
100
|
+
"Plan Compiler": C.green, FL: C.yellow, "Feature Lead": C.yellow,
|
|
101
|
+
};
|
|
102
|
+
const colorized = text.replace(/\*?\[([^\]]+)\]\*?/g, (match, name) => {
|
|
103
|
+
const color = AGENT_TAG_COLORS[name];
|
|
104
|
+
if (color) return `${color}${C.bold}[${name}]${C.reset}`;
|
|
105
|
+
return match;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const msg = `${C.dim}[${slug}]${C.reset} ${labelColor}${C.bold}[${agentLabel}]${C.reset} ${colorized}`;
|
|
109
|
+
this._print(msg);
|
|
110
|
+
this._appendLog(slug, `[${agentLabel}] ${text}`);
|
|
111
|
+
queries.insertEvent(featureId, "agent-response", `agent:${agentLabel}`, rawContent);
|
|
112
|
+
return { ref: Date.now().toString() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async uploadArtifact(featureId, filePath, title) {
|
|
116
|
+
const feature = queries.getFeatureById(featureId);
|
|
117
|
+
const slug = feature?.slug || "unknown";
|
|
118
|
+
// Track for inclusion in the next decision block
|
|
119
|
+
this._recentArtifacts.push({ title, filePath });
|
|
120
|
+
this._appendLog(slug, `Artifact: ${title} at ${filePath}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async postDecision(featureId, decision) {
|
|
124
|
+
const feature = queries.getFeatureById(featureId);
|
|
125
|
+
const slug = feature?.slug || "unknown";
|
|
126
|
+
const C = {
|
|
127
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
128
|
+
yellow: "\x1b[33m", bgYellow: "\x1b[43m", black: "\x1b[30m",
|
|
129
|
+
white: "\x1b[37m", green: "\x1b[32m", red: "\x1b[31m",
|
|
130
|
+
cyan: "\x1b[36m",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const w = (this._inputHandler?._cols) || (process.stdout.columns || 80);
|
|
134
|
+
const rule = `${C.yellow}${"โ".repeat(w)}${C.reset}`;
|
|
135
|
+
|
|
136
|
+
this._print("");
|
|
137
|
+
this._print(rule);
|
|
138
|
+
this._print("");
|
|
139
|
+
|
|
140
|
+
// Title with badge
|
|
141
|
+
const badge = `${C.bgYellow}${C.black}${C.bold} ACTION REQUIRED ${C.reset}`;
|
|
142
|
+
this._print(` ${badge} ${C.bold}${decision.title}${C.reset}`);
|
|
143
|
+
|
|
144
|
+
// Context
|
|
145
|
+
if (decision.context) {
|
|
146
|
+
this._print(` ${C.dim}${decision.context}${C.reset}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Review URLs
|
|
150
|
+
if (decision.reviewUrl || decision.qaUrl) {
|
|
151
|
+
this._print("");
|
|
152
|
+
this._print(` ${C.cyan}${C.bold}Review & Annotate:${C.reset}`);
|
|
153
|
+
if (decision.reviewUrl) {
|
|
154
|
+
this._print(` ${C.cyan}๐ Doc Review${C.reset} ${C.bold}${decision.reviewUrl}${C.reset}`);
|
|
155
|
+
}
|
|
156
|
+
if (decision.qaUrl) {
|
|
157
|
+
this._print(` ${C.cyan}๐งช QA Test${C.reset} ${C.bold}${decision.qaUrl}${C.reset}`);
|
|
158
|
+
}
|
|
159
|
+
this._print(` ${C.dim}Open in browser to review with annotation tools.${C.reset}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Artifacts
|
|
163
|
+
const artifacts = this._recentArtifacts;
|
|
164
|
+
if (artifacts.length > 0) {
|
|
165
|
+
this._print("");
|
|
166
|
+
this._print(` ${C.cyan}${C.bold}Artifacts:${C.reset}`);
|
|
167
|
+
for (const a of artifacts) {
|
|
168
|
+
this._print(` ${C.cyan}๐ ${a.title}${C.reset} ${C.dim}${a.filePath}${C.reset}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._print("");
|
|
173
|
+
|
|
174
|
+
// Options
|
|
175
|
+
for (const opt of decision.options) {
|
|
176
|
+
const color = opt.style === "primary" ? C.green : opt.style === "danger" ? C.red : C.white;
|
|
177
|
+
this._print(` ${color}${C.bold}โธ ${opt.label}${C.reset} ${C.dim}type: ${C.bold}${opt.id}${C.reset}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this._print("");
|
|
181
|
+
this._print(` ${C.dim}Reply with an option id (e.g. "${decision.options[0]?.id}") or ask the Operator.${C.reset}`);
|
|
182
|
+
this._print("");
|
|
183
|
+
this._print(rule);
|
|
184
|
+
this._print("");
|
|
185
|
+
|
|
186
|
+
// Clear tracked artifacts after rendering
|
|
187
|
+
this._recentArtifacts = [];
|
|
188
|
+
|
|
189
|
+
this._appendLog(slug, `Decision: ${decision.title}`);
|
|
190
|
+
return { ref: decision.id };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async resolveDecisionMessage(featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback) {
|
|
194
|
+
const feature = queries.getFeatureById(featureId);
|
|
195
|
+
const slug = feature?.slug || "unknown";
|
|
196
|
+
const C = { reset: "\x1b[0m", dim: "\x1b[2m", green: "\x1b[32m", bold: "\x1b[1m" };
|
|
197
|
+
this._print(`${C.dim}[${slug}]${C.reset} ${C.green}${C.bold}โ Decision resolved:${C.reset} ${selectedLabel}${feedback ? ` ${C.dim}(${feedback})${C.reset}` : ""}`);
|
|
198
|
+
this._appendLog(slug, `Decision "${decisionId}" resolved: ${selectedLabel}`);
|
|
199
|
+
|
|
200
|
+
// Clean up pending decision file
|
|
201
|
+
const decisionFile = path.join(this.logDir, slug, `.pending-decision-${decisionId}`);
|
|
202
|
+
try { fs.unlinkSync(decisionFile); } catch { /* ok */ }
|
|
203
|
+
|
|
204
|
+
// Emit lifecycle events for plan-approval decisions
|
|
205
|
+
if (decisionId === "plan-approval") {
|
|
206
|
+
const updatedFeature = queries.getFeatureById(featureId);
|
|
207
|
+
if (updatedFeature?.phase === "impl" || updatedFeature?.phase === "plan-approval") {
|
|
208
|
+
this._events.emit("plan-approved", featureId);
|
|
209
|
+
} else if (updatedFeature?.phase === "planning") {
|
|
210
|
+
this._events.emit("plan-rejected", featureId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async postPlanForApproval(featureId, planDir) {
|
|
216
|
+
const feature = queries.getFeatureById(featureId);
|
|
217
|
+
const slug = feature?.slug || "unknown";
|
|
218
|
+
const C = { reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m", bgBlue: "\x1b[44m", white: "\x1b[37m" };
|
|
219
|
+
this._print(`${C.dim}[${slug}]${C.reset} ${C.bgBlue}${C.white} Pipeline ${C.reset} ${C.bold}Plan ready for approval.${C.reset} ${C.dim}Artifacts: ${planDir}${C.reset}`);
|
|
220
|
+
this._appendLog(slug, `Plan ready for approval. Artifacts in: ${planDir}`);
|
|
221
|
+
|
|
222
|
+
return await this.postDecision(featureId, {
|
|
223
|
+
id: "plan-approval",
|
|
224
|
+
title: "Plan ready for approval",
|
|
225
|
+
context: "All planning phases complete. Review the artifacts.",
|
|
226
|
+
options: [
|
|
227
|
+
{ id: "approve", label: "Approve Plan", style: "primary" },
|
|
228
|
+
{ id: "reject", label: "Reject Plan", style: "danger" },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async postFeatureComplete(featureId) {
|
|
234
|
+
const feature = queries.getFeatureById(featureId);
|
|
235
|
+
const slug = feature?.slug || "unknown";
|
|
236
|
+
const C = { reset: "\x1b[0m", bold: "\x1b[1m", green: "\x1b[32m", bgGreen: "\x1b[42m", white: "\x1b[37m" };
|
|
237
|
+
this._print(`\n${C.bgGreen}${C.white}${C.bold} COMPLETE ${C.reset} ${C.green}${C.bold}Feature ${slug} โ all gates passed!${C.reset}\n`);
|
|
238
|
+
this._appendLog(slug, "Feature complete! All gates passed.");
|
|
239
|
+
this._events.emit("feature-complete", featureId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_appendLog(slug, text) {
|
|
243
|
+
const logFile = path.join(this.logDir, slug, "bridge.log");
|
|
244
|
+
const timestamp = new Date().toISOString();
|
|
245
|
+
try {
|
|
246
|
+
fs.appendFileSync(logFile, `[${timestamp}] ${text}\n`);
|
|
247
|
+
} catch { /* log dir may not exist yet */ }
|
|
248
|
+
}
|
|
249
|
+
}
|