@jonit-dev/night-watch-cli 1.1.3 → 1.1.5
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/README.md +48 -426
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts +16 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +155 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +10 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/prd.d.ts +24 -0
- package/dist/commands/prd.d.ts.map +1 -0
- package/dist/commands/prd.js +283 -0
- package/dist/commands/prd.js.map +1 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +28 -0
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts +19 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +64 -6
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +27 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +67 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +5 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -1
- package/dist/templates/prd-template.d.ts +11 -0
- package/dist/templates/prd-template.d.ts.map +1 -0
- package/dist/templates/prd-template.js +166 -0
- package/dist/templates/prd-template.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/github.d.ts +30 -0
- package/dist/utils/github.d.ts.map +1 -0
- package/dist/utils/github.js +104 -0
- package/dist/utils/github.js.map +1 -0
- package/dist/utils/notify.d.ts +63 -0
- package/dist/utils/notify.d.ts.map +1 -0
- package/dist/utils/notify.js +237 -0
- package/dist/utils/notify.js.map +1 -0
- package/package.json +4 -4
- package/scripts/night-watch-cron.sh +13 -2
- package/scripts/night-watch-helpers.sh +51 -0
- package/scripts/test-helpers.bats +77 -0
- package/templates/prd.md +26 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification utilities for Night Watch CLI
|
|
3
|
+
* Sends webhook notifications to Slack, Discord, and Telegram
|
|
4
|
+
*/
|
|
5
|
+
import { warn, info } from "./ui.js";
|
|
6
|
+
import { extractSummary } from "./github.js";
|
|
7
|
+
/**
|
|
8
|
+
* Get the emoji for a notification event
|
|
9
|
+
*/
|
|
10
|
+
export function getEventEmoji(event) {
|
|
11
|
+
switch (event) {
|
|
12
|
+
case "run_succeeded":
|
|
13
|
+
return "\u2705";
|
|
14
|
+
case "run_failed":
|
|
15
|
+
return "\u274C";
|
|
16
|
+
case "run_timeout":
|
|
17
|
+
return "\u23F0";
|
|
18
|
+
case "review_completed":
|
|
19
|
+
return "\uD83D\uDD0D";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get a human-readable title for a notification event
|
|
24
|
+
*/
|
|
25
|
+
export function getEventTitle(event) {
|
|
26
|
+
switch (event) {
|
|
27
|
+
case "run_succeeded":
|
|
28
|
+
return "PRD Execution Succeeded";
|
|
29
|
+
case "run_failed":
|
|
30
|
+
return "PRD Execution Failed";
|
|
31
|
+
case "run_timeout":
|
|
32
|
+
return "PRD Execution Timed Out";
|
|
33
|
+
case "review_completed":
|
|
34
|
+
return "PR Review Completed";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the Discord embed color for a notification event
|
|
39
|
+
*/
|
|
40
|
+
export function getEventColor(event) {
|
|
41
|
+
switch (event) {
|
|
42
|
+
case "run_succeeded":
|
|
43
|
+
return 0x00ff00;
|
|
44
|
+
case "run_failed":
|
|
45
|
+
return 0xff0000;
|
|
46
|
+
case "run_timeout":
|
|
47
|
+
return 0xff0000;
|
|
48
|
+
case "review_completed":
|
|
49
|
+
return 0x0099ff;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build a description string from notification context
|
|
54
|
+
*/
|
|
55
|
+
export function buildDescription(ctx) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
lines.push(`Project: ${ctx.projectName}`);
|
|
58
|
+
lines.push(`Provider: ${ctx.provider}`);
|
|
59
|
+
lines.push(`Exit code: ${ctx.exitCode}`);
|
|
60
|
+
if (ctx.prdName) {
|
|
61
|
+
lines.push(`PRD: ${ctx.prdName}`);
|
|
62
|
+
}
|
|
63
|
+
if (ctx.branchName) {
|
|
64
|
+
lines.push(`Branch: ${ctx.branchName}`);
|
|
65
|
+
}
|
|
66
|
+
if (ctx.prNumber !== undefined) {
|
|
67
|
+
lines.push(`PR: #${ctx.prNumber}`);
|
|
68
|
+
}
|
|
69
|
+
if (ctx.duration !== undefined) {
|
|
70
|
+
lines.push(`Duration: ${ctx.duration}s`);
|
|
71
|
+
}
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Escape special characters for Telegram MarkdownV2 format
|
|
76
|
+
*/
|
|
77
|
+
function escapeMarkdownV2(text) {
|
|
78
|
+
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, "\\$&");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Format a notification payload for Slack incoming webhooks
|
|
82
|
+
*/
|
|
83
|
+
export function formatSlackPayload(ctx) {
|
|
84
|
+
const emoji = getEventEmoji(ctx.event);
|
|
85
|
+
const title = getEventTitle(ctx.event);
|
|
86
|
+
const description = buildDescription(ctx);
|
|
87
|
+
let color;
|
|
88
|
+
if (ctx.event === "run_succeeded") {
|
|
89
|
+
color = "#00ff00";
|
|
90
|
+
}
|
|
91
|
+
else if (ctx.event === "review_completed") {
|
|
92
|
+
color = "#0099ff";
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
color = "#ff0000";
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
attachments: [
|
|
99
|
+
{
|
|
100
|
+
color,
|
|
101
|
+
blocks: [
|
|
102
|
+
{
|
|
103
|
+
type: "section",
|
|
104
|
+
text: {
|
|
105
|
+
type: "mrkdwn",
|
|
106
|
+
text: `*${emoji} ${title}*\n${description}`,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Format a notification payload for Discord webhooks
|
|
116
|
+
*/
|
|
117
|
+
export function formatDiscordPayload(ctx) {
|
|
118
|
+
const emoji = getEventEmoji(ctx.event);
|
|
119
|
+
const title = getEventTitle(ctx.event);
|
|
120
|
+
const description = buildDescription(ctx);
|
|
121
|
+
return {
|
|
122
|
+
embeds: [
|
|
123
|
+
{
|
|
124
|
+
title: `${emoji} ${title}`,
|
|
125
|
+
description,
|
|
126
|
+
color: getEventColor(ctx.event),
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Build a structured Telegram message when PR details are available.
|
|
134
|
+
* Falls back to the basic format when they are not.
|
|
135
|
+
*/
|
|
136
|
+
export function formatTelegramPayload(ctx) {
|
|
137
|
+
const emoji = getEventEmoji(ctx.event);
|
|
138
|
+
const title = getEventTitle(ctx.event);
|
|
139
|
+
// If PR details are present, use the rich structured template
|
|
140
|
+
if (ctx.prUrl && ctx.prTitle) {
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(`*${escapeMarkdownV2(emoji + " " + title)}*`);
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push(`${escapeMarkdownV2("📋")} *${escapeMarkdownV2("PR #" + (ctx.prNumber ?? "") + ": " + ctx.prTitle)}*`);
|
|
145
|
+
lines.push(`${escapeMarkdownV2("🔗")} ${escapeMarkdownV2(ctx.prUrl)}`);
|
|
146
|
+
// Summary from PR body
|
|
147
|
+
if (ctx.prBody && ctx.prBody.trim().length > 0) {
|
|
148
|
+
const summary = extractSummary(ctx.prBody);
|
|
149
|
+
if (summary) {
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push(`${escapeMarkdownV2("📝 Summary")}`);
|
|
152
|
+
lines.push(escapeMarkdownV2(summary));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Stats
|
|
156
|
+
if (ctx.filesChanged !== undefined || ctx.additions !== undefined) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(`${escapeMarkdownV2("📊 Stats")}`);
|
|
159
|
+
const stats = [];
|
|
160
|
+
if (ctx.filesChanged !== undefined) {
|
|
161
|
+
stats.push(`Files changed: ${ctx.filesChanged}`);
|
|
162
|
+
}
|
|
163
|
+
if (ctx.additions !== undefined && ctx.deletions !== undefined) {
|
|
164
|
+
stats.push(`+${ctx.additions} / -${ctx.deletions}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push(escapeMarkdownV2(stats.join(" | ")));
|
|
167
|
+
}
|
|
168
|
+
// Footer
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(escapeMarkdownV2(`⚙️ Project: ${ctx.projectName} | Provider: ${ctx.provider}`));
|
|
171
|
+
return {
|
|
172
|
+
text: lines.join("\n"),
|
|
173
|
+
parse_mode: "MarkdownV2",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Fallback: basic format (no PR details)
|
|
177
|
+
const description = buildDescription(ctx);
|
|
178
|
+
return {
|
|
179
|
+
text: `*${escapeMarkdownV2(emoji + " " + title)}*\n\n${escapeMarkdownV2(description)}`,
|
|
180
|
+
parse_mode: "MarkdownV2",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Send a notification to a single webhook endpoint
|
|
185
|
+
* Silently catches errors — never throws
|
|
186
|
+
*/
|
|
187
|
+
export async function sendWebhook(webhook, ctx) {
|
|
188
|
+
// Skip if this event is not in the webhook's configured events
|
|
189
|
+
if (!webhook.events.includes(ctx.event)) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
let url;
|
|
194
|
+
let body;
|
|
195
|
+
switch (webhook.type) {
|
|
196
|
+
case "slack": {
|
|
197
|
+
url = webhook.url;
|
|
198
|
+
body = JSON.stringify(formatSlackPayload(ctx));
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "discord": {
|
|
202
|
+
url = webhook.url;
|
|
203
|
+
body = JSON.stringify(formatDiscordPayload(ctx));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "telegram": {
|
|
207
|
+
url = `https://api.telegram.org/bot${webhook.botToken}/sendMessage`;
|
|
208
|
+
const telegramPayload = formatTelegramPayload(ctx);
|
|
209
|
+
body = JSON.stringify({ chat_id: webhook.chatId, ...telegramPayload });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
await fetch(url, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
221
|
+
warn(`Notification failed (${webhook.type}): ${message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Send notifications to all configured webhooks
|
|
226
|
+
*/
|
|
227
|
+
export async function sendNotifications(config, ctx) {
|
|
228
|
+
if (!config.notifications || config.notifications.webhooks.length === 0) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const webhooks = config.notifications.webhooks;
|
|
232
|
+
const results = await Promise.allSettled(webhooks.map((wh) => sendWebhook(wh, ctx)));
|
|
233
|
+
const sent = results.filter((r) => r.status === "fulfilled").length;
|
|
234
|
+
const total = webhooks.length;
|
|
235
|
+
info(`Sent ${sent}/${total} notifications`);
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=notify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notify.js","sourceRoot":"","sources":["../../src/utils/notify.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jonit-dev/night-watch-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Autonomous PRD execution using AI Provider CLIs + cron",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"license": "MIT",
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/
|
|
43
|
+
"url": "https://github.com/jonit-dev/night-watch-cli.git"
|
|
44
44
|
},
|
|
45
|
-
"homepage": "https://github.com/
|
|
45
|
+
"homepage": "https://github.com/jonit-dev/night-watch-cli#readme",
|
|
46
46
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/
|
|
47
|
+
"url": "https://github.com/jonit-dev/night-watch-cli/issues"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"chalk": "^5.6.2",
|
|
@@ -55,16 +55,26 @@ fi
|
|
|
55
55
|
|
|
56
56
|
cleanup_worktrees "${PROJECT_DIR}"
|
|
57
57
|
|
|
58
|
-
ELIGIBLE_PRD=$(find_eligible_prd "${PRD_DIR}")
|
|
58
|
+
ELIGIBLE_PRD=$(find_eligible_prd "${PRD_DIR}" "${MAX_RUNTIME}")
|
|
59
59
|
|
|
60
60
|
if [ -z "${ELIGIBLE_PRD}" ]; then
|
|
61
61
|
log "SKIP: No eligible PRDs (all done, in-progress, or blocked)"
|
|
62
62
|
exit 0
|
|
63
63
|
fi
|
|
64
64
|
|
|
65
|
+
# Claim the PRD to prevent other runs from selecting it
|
|
66
|
+
claim_prd "${PRD_DIR}" "${ELIGIBLE_PRD}"
|
|
67
|
+
|
|
68
|
+
# Update EXIT trap to also release claim
|
|
69
|
+
trap "rm -f '${LOCK_FILE}'; release_claim '${PRD_DIR}' '${ELIGIBLE_PRD}'" EXIT
|
|
70
|
+
|
|
65
71
|
PRD_NAME="${ELIGIBLE_PRD%.md}"
|
|
66
72
|
BRANCH_NAME="night-watch/${PRD_NAME}"
|
|
67
|
-
|
|
73
|
+
if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
|
|
74
|
+
DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
|
|
75
|
+
else
|
|
76
|
+
DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
|
|
77
|
+
fi
|
|
68
78
|
|
|
69
79
|
log "START: Processing ${ELIGIBLE_PRD} on branch ${BRANCH_NAME}"
|
|
70
80
|
|
|
@@ -142,6 +152,7 @@ esac
|
|
|
142
152
|
if [ ${EXIT_CODE} -eq 0 ]; then
|
|
143
153
|
PR_EXISTS=$(gh pr list --state open --json headRefName --jq '.[].headRefName' 2>/dev/null | grep -cF "${BRANCH_NAME}" || echo "0")
|
|
144
154
|
if [ "${PR_EXISTS}" -gt 0 ]; then
|
|
155
|
+
release_claim "${PRD_DIR}" "${ELIGIBLE_PRD}"
|
|
145
156
|
mark_prd_done "${PRD_DIR}" "${ELIGIBLE_PRD}"
|
|
146
157
|
git -C "${PROJECT_DIR}" add -A docs/PRDs/night-watch/
|
|
147
158
|
git -C "${PROJECT_DIR}" commit -m "chore: mark ${ELIGIBLE_PRD} as done (PR opened on ${BRANCH_NAME})
|
|
@@ -118,10 +118,55 @@ detect_default_branch() {
|
|
|
118
118
|
echo "main"
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
# ── Claim management ─────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
claim_prd() {
|
|
124
|
+
local prd_dir="${1:?prd_dir required}"
|
|
125
|
+
local prd_file="${2:?prd_file required}"
|
|
126
|
+
local claim_file="${prd_dir}/${prd_file}.claim"
|
|
127
|
+
|
|
128
|
+
printf '{"timestamp":%d,"hostname":"%s","pid":%d}\n' \
|
|
129
|
+
"$(date +%s)" "$(hostname)" "$$" > "${claim_file}"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
release_claim() {
|
|
133
|
+
local prd_dir="${1:?prd_dir required}"
|
|
134
|
+
local prd_file="${2:?prd_file required}"
|
|
135
|
+
local claim_file="${prd_dir}/${prd_file}.claim"
|
|
136
|
+
|
|
137
|
+
rm -f "${claim_file}"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
is_claimed() {
|
|
141
|
+
local prd_dir="${1:?prd_dir required}"
|
|
142
|
+
local prd_file="${2:?prd_file required}"
|
|
143
|
+
local max_runtime="${3:-7200}"
|
|
144
|
+
local claim_file="${prd_dir}/${prd_file}.claim"
|
|
145
|
+
|
|
146
|
+
if [ ! -f "${claim_file}" ]; then
|
|
147
|
+
return 1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
local claim_ts
|
|
151
|
+
claim_ts=$(grep -o '"timestamp":[0-9]*' "${claim_file}" 2>/dev/null | grep -o '[0-9]*' || echo "0")
|
|
152
|
+
local now
|
|
153
|
+
now=$(date +%s)
|
|
154
|
+
local age=$(( now - claim_ts ))
|
|
155
|
+
|
|
156
|
+
if [ "${age}" -lt "${max_runtime}" ]; then
|
|
157
|
+
return 0 # actively claimed
|
|
158
|
+
else
|
|
159
|
+
# Stale claim — remove it
|
|
160
|
+
rm -f "${claim_file}"
|
|
161
|
+
return 1
|
|
162
|
+
fi
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
# ── Find next eligible PRD ───────────────────────────────────────────────────
|
|
122
166
|
|
|
123
167
|
find_eligible_prd() {
|
|
124
168
|
local prd_dir="${1:?prd_dir required}"
|
|
169
|
+
local max_runtime="${2:-7200}"
|
|
125
170
|
local done_dir="${prd_dir}/done"
|
|
126
171
|
|
|
127
172
|
local prd_files
|
|
@@ -139,6 +184,12 @@ find_eligible_prd() {
|
|
|
139
184
|
prd_file=$(basename "${prd_path}")
|
|
140
185
|
local prd_name="${prd_file%.md}"
|
|
141
186
|
|
|
187
|
+
# Skip if claimed by another process
|
|
188
|
+
if is_claimed "${prd_dir}" "${prd_file}" "${max_runtime}"; then
|
|
189
|
+
log "SKIP-PRD: ${prd_file} — claimed by another process"
|
|
190
|
+
continue
|
|
191
|
+
fi
|
|
192
|
+
|
|
142
193
|
# Skip if a PR already exists for this PRD
|
|
143
194
|
if echo "${open_branches}" | grep -qF "${prd_name}"; then
|
|
144
195
|
log "SKIP-PRD: ${prd_file} — open PR already exists"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Tests for night-watch-helpers.sh claim functions
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
# Source the helpers
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
|
8
|
+
|
|
9
|
+
# Set required globals
|
|
10
|
+
export LOG_FILE="/tmp/night-watch-test-$$.log"
|
|
11
|
+
|
|
12
|
+
source "${SCRIPT_DIR}/night-watch-helpers.sh"
|
|
13
|
+
|
|
14
|
+
# Create temp PRD directory
|
|
15
|
+
TEST_PRD_DIR=$(mktemp -d)
|
|
16
|
+
echo "# Test PRD" > "${TEST_PRD_DIR}/01-test-prd.md"
|
|
17
|
+
echo "# Test PRD 2" > "${TEST_PRD_DIR}/02-test-prd.md"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
teardown() {
|
|
21
|
+
rm -rf "${TEST_PRD_DIR}"
|
|
22
|
+
rm -f "${LOG_FILE}"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@test "claim_prd creates .claim file with JSON" {
|
|
26
|
+
claim_prd "${TEST_PRD_DIR}" "01-test-prd.md"
|
|
27
|
+
|
|
28
|
+
[ -f "${TEST_PRD_DIR}/01-test-prd.md.claim" ]
|
|
29
|
+
|
|
30
|
+
local content
|
|
31
|
+
content=$(cat "${TEST_PRD_DIR}/01-test-prd.md.claim")
|
|
32
|
+
|
|
33
|
+
# Check JSON contains expected fields
|
|
34
|
+
echo "${content}" | grep -q '"timestamp":'
|
|
35
|
+
echo "${content}" | grep -q '"hostname":'
|
|
36
|
+
echo "${content}" | grep -q '"pid":'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@test "is_claimed returns 0 for active claim" {
|
|
40
|
+
claim_prd "${TEST_PRD_DIR}" "01-test-prd.md"
|
|
41
|
+
|
|
42
|
+
run is_claimed "${TEST_PRD_DIR}" "01-test-prd.md" 7200
|
|
43
|
+
[ "$status" -eq 0 ]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@test "is_claimed returns 1 for stale claim" {
|
|
47
|
+
# Write a claim with an old timestamp (1 second)
|
|
48
|
+
printf '{"timestamp":1000000000,"hostname":"test","pid":1}\n' \
|
|
49
|
+
> "${TEST_PRD_DIR}/01-test-prd.md.claim"
|
|
50
|
+
|
|
51
|
+
run is_claimed "${TEST_PRD_DIR}" "01-test-prd.md" 7200
|
|
52
|
+
[ "$status" -eq 1 ]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@test "is_claimed returns 1 for no claim" {
|
|
56
|
+
run is_claimed "${TEST_PRD_DIR}" "01-test-prd.md" 7200
|
|
57
|
+
[ "$status" -eq 1 ]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@test "release_claim removes .claim file" {
|
|
61
|
+
claim_prd "${TEST_PRD_DIR}" "01-test-prd.md"
|
|
62
|
+
[ -f "${TEST_PRD_DIR}/01-test-prd.md.claim" ]
|
|
63
|
+
|
|
64
|
+
release_claim "${TEST_PRD_DIR}" "01-test-prd.md"
|
|
65
|
+
[ ! -f "${TEST_PRD_DIR}/01-test-prd.md.claim" ]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@test "find_eligible_prd skips claimed PRD" {
|
|
69
|
+
# Claim the first PRD
|
|
70
|
+
claim_prd "${TEST_PRD_DIR}" "01-test-prd.md"
|
|
71
|
+
|
|
72
|
+
# find_eligible_prd should skip 01 and return 02
|
|
73
|
+
local result
|
|
74
|
+
result=$(find_eligible_prd "${TEST_PRD_DIR}" 7200)
|
|
75
|
+
|
|
76
|
+
[ "${result}" = "02-test-prd.md" ]
|
|
77
|
+
}
|
package/templates/prd.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# PRD: {{TITLE}}
|
|
2
|
+
|
|
3
|
+
{{DEPENDS_ON}}
|
|
4
|
+
|
|
5
|
+
**Complexity: {{COMPLEXITY_SCORE}} → {{COMPLEXITY_LEVEL}} mode**
|
|
6
|
+
{{COMPLEXITY_BREAKDOWN}}
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Problem
|
|
11
|
+
|
|
12
|
+
<!-- What problem does this solve? Describe in 1-2 sentences. -->
|
|
13
|
+
|
|
14
|
+
## Solution
|
|
15
|
+
|
|
16
|
+
<!-- How will you solve it? 3-5 bullets explaining the approach. -->
|
|
17
|
+
|
|
18
|
+
## Phases
|
|
19
|
+
|
|
20
|
+
{{PHASES}}
|
|
21
|
+
|
|
22
|
+
## Acceptance Criteria
|
|
23
|
+
|
|
24
|
+
- [ ] All phases complete
|
|
25
|
+
- [ ] All tests pass
|
|
26
|
+
- [ ] Feature is reachable (not orphaned code)
|