wowbagger 0.1.0-alpha.2 → 0.1.0-alpha.4
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/CHANGELOG.md +29 -0
- package/README.md +74 -35
- package/adapters/claude-code/wowbagger-adapter.json +1 -1
- package/docs/adapter-contract.md +1280 -0
- package/docs/work-claim-contract.md +21 -4
- package/package.json +3 -1
- package/skills/wowbagger/SKILL.md +37 -5
- package/src/adapter/entrypoint-main.js +19 -5
- package/src/claim-coordinator.js +113 -7
- package/src/claim-journal.js +45 -2
- package/src/claim-publication.js +121 -59
- package/src/claim-store.js +20 -8
- package/src/cli.js +118 -11
- package/src/mutation.js +151 -32
- package/src/ready.js +64 -38
- package/src/report-html.js +198 -0
- package/src/report-markdown.js +171 -0
- package/src/report.js +396 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectReadiness } from './ready.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const REPORT_FILE_SYSTEM = { mkdir, open, rename, rm };
|
|
7
|
+
const LOGO_MIME_TYPES = new Map([
|
|
8
|
+
['.svg', 'image/svg+xml'],
|
|
9
|
+
['.png', 'image/png'],
|
|
10
|
+
['.jpg', 'image/jpeg'],
|
|
11
|
+
['.jpeg', 'image/jpeg'],
|
|
12
|
+
['.webp', 'image/webp'],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const CONFIG_KEYS = new Set(['report_version', 'repository', 'title', 'output', 'fields', 'swarm']);
|
|
16
|
+
const REPOSITORY_KEYS = new Set(['name', 'logo']);
|
|
17
|
+
const FIELD_KEYS = new Set([
|
|
18
|
+
'area',
|
|
19
|
+
'rank',
|
|
20
|
+
'score',
|
|
21
|
+
'complexity',
|
|
22
|
+
'tier',
|
|
23
|
+
'mandate',
|
|
24
|
+
'severity',
|
|
25
|
+
'confidence',
|
|
26
|
+
'security',
|
|
27
|
+
'priority_base',
|
|
28
|
+
'priority_component',
|
|
29
|
+
'priority_impact',
|
|
30
|
+
'priority_leverage',
|
|
31
|
+
'priority_rationale',
|
|
32
|
+
'completion_reference',
|
|
33
|
+
]);
|
|
34
|
+
const SWARM_KEYS = new Set(['eligible_complexities']);
|
|
35
|
+
|
|
36
|
+
export class ReportError extends Error {
|
|
37
|
+
constructor(code, message, details) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'ReportError';
|
|
40
|
+
this.code = code;
|
|
41
|
+
if (details !== undefined) {
|
|
42
|
+
this.details = details;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolvePointer(value, pointer) {
|
|
48
|
+
if (pointer === '') {
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let current = value;
|
|
53
|
+
for (const encodedSegment of pointer.slice(1).split('/')) {
|
|
54
|
+
const segment = encodedSegment.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
55
|
+
if (current === null
|
|
56
|
+
|| (typeof current !== 'object' && typeof current !== 'function')
|
|
57
|
+
|| !Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
current = current[segment];
|
|
61
|
+
}
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildReportModel(items, config, asOf) {
|
|
66
|
+
const readinessById = projectReadiness(items, asOf);
|
|
67
|
+
const projected = items.map((item) => projectItem(item, config.fields, readinessById.get(item.data.id)));
|
|
68
|
+
const reportItems = projected
|
|
69
|
+
.filter((item) => item.terminalDate === null)
|
|
70
|
+
.sort(compareProjectedItems);
|
|
71
|
+
const terminalItems = projected
|
|
72
|
+
.filter((item) => item.terminalDate !== null)
|
|
73
|
+
.sort((left, right) => compareText(right.terminalDate, left.terminalDate) || compareText(left.id, right.id));
|
|
74
|
+
|
|
75
|
+
const swarmBatches = config.swarm === null
|
|
76
|
+
? []
|
|
77
|
+
: buildSwarmBatches(reportItems, config.swarm.eligibleComplexities);
|
|
78
|
+
return {
|
|
79
|
+
reportVersion: config.reportVersion,
|
|
80
|
+
repository: config.repository,
|
|
81
|
+
title: config.title,
|
|
82
|
+
asOf,
|
|
83
|
+
items: reportItems,
|
|
84
|
+
terminalItems,
|
|
85
|
+
stats: {
|
|
86
|
+
total: projected.length,
|
|
87
|
+
open: reportItems.length,
|
|
88
|
+
terminal: terminalItems.length,
|
|
89
|
+
ready: reportItems.filter((item) => item.readiness.state === 'ready').length,
|
|
90
|
+
blocked: reportItems.filter((item) => item.readiness.state === 'blocked').length,
|
|
91
|
+
ineligible: reportItems.filter((item) => item.readiness.state === 'ineligible').length,
|
|
92
|
+
triage: projected.filter((item) => item.status === 'triage').length,
|
|
93
|
+
inProgress: projected.filter((item) => item.status === 'in-progress').length,
|
|
94
|
+
snoozed: items.filter((item) => item.data.snoozed_until > asOf).length,
|
|
95
|
+
done: terminalItems.filter((item) => item.status === 'done').length,
|
|
96
|
+
killed: terminalItems.filter((item) => item.status === 'killed').length,
|
|
97
|
+
deferred: terminalItems.filter((item) => item.status === 'deferred').length,
|
|
98
|
+
archived: terminalItems.filter((item) => item.status === 'archived').length,
|
|
99
|
+
},
|
|
100
|
+
swarm: config.swarm,
|
|
101
|
+
swarmBatches,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function projectItem(item, fieldMappings, readiness) {
|
|
106
|
+
const { data } = item;
|
|
107
|
+
const fields = {};
|
|
108
|
+
for (const [slot, pointer] of Object.entries(fieldMappings)) {
|
|
109
|
+
const value = resolvePointer(data, pointer);
|
|
110
|
+
if (value !== null && ['string', 'number', 'boolean'].includes(typeof value)) {
|
|
111
|
+
fields[slot] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
id: data.id,
|
|
118
|
+
number: data.number ?? null,
|
|
119
|
+
title: data.title,
|
|
120
|
+
kind: data.kind,
|
|
121
|
+
status: data.status,
|
|
122
|
+
created: data.created,
|
|
123
|
+
updated: data.updated,
|
|
124
|
+
terminalDate: terminalDate(data),
|
|
125
|
+
priority: data.priority ?? null,
|
|
126
|
+
parent: data.parent ?? null,
|
|
127
|
+
dependsOn: data.depends_on ?? [],
|
|
128
|
+
related: data.related ?? [],
|
|
129
|
+
decisions: data.decisions ?? [],
|
|
130
|
+
body: item.body,
|
|
131
|
+
readiness,
|
|
132
|
+
fields,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function buildSwarmBatches(items, eligibleComplexities) {
|
|
136
|
+
const pool = items.filter((item) => item.readiness.state === 'ready'
|
|
137
|
+
&& isNonEmptyString(item.fields.area)
|
|
138
|
+
&& isNonEmptyString(item.fields.complexity)
|
|
139
|
+
&& eligibleComplexities.includes(item.fields.complexity));
|
|
140
|
+
const batches = [];
|
|
141
|
+
|
|
142
|
+
while (pool.length > 0 && batches.length < 8) {
|
|
143
|
+
const batch = [];
|
|
144
|
+
const usedAreas = new Set();
|
|
145
|
+
for (let index = 0; index < pool.length && batch.length < 6;) {
|
|
146
|
+
const candidate = pool[index];
|
|
147
|
+
if (usedAreas.has(candidate.fields.area)) {
|
|
148
|
+
index += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
usedAreas.add(candidate.fields.area);
|
|
152
|
+
batch.push(candidate);
|
|
153
|
+
pool.splice(index, 1);
|
|
154
|
+
}
|
|
155
|
+
batches.push(batch);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return batches;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function terminalDate(data) {
|
|
162
|
+
const field = {
|
|
163
|
+
done: 'completed',
|
|
164
|
+
killed: 'killed',
|
|
165
|
+
archived: 'archived',
|
|
166
|
+
deferred: 'deferred',
|
|
167
|
+
}[data.status];
|
|
168
|
+
return field === undefined ? null : data[field];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function writeReportFile(outputPath, html, overrides = {}) {
|
|
172
|
+
const fileSystem = { ...REPORT_FILE_SYSTEM, ...overrides };
|
|
173
|
+
const directory = path.dirname(outputPath);
|
|
174
|
+
const temporaryPath = path.join(
|
|
175
|
+
directory,
|
|
176
|
+
`.${path.basename(outputPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
177
|
+
);
|
|
178
|
+
let handle = null;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await fileSystem.mkdir(directory, { recursive: true });
|
|
182
|
+
handle = await fileSystem.open(temporaryPath, 'wx', 0o644);
|
|
183
|
+
await handle.writeFile(html, 'utf8');
|
|
184
|
+
await handle.sync();
|
|
185
|
+
await handle.close();
|
|
186
|
+
handle = null;
|
|
187
|
+
await fileSystem.rename(temporaryPath, outputPath);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (handle !== null) {
|
|
190
|
+
await handle.close().catch(() => {});
|
|
191
|
+
}
|
|
192
|
+
let cleanupError = null;
|
|
193
|
+
try {
|
|
194
|
+
await fileSystem.rm(temporaryPath, { force: true });
|
|
195
|
+
} catch (candidate) {
|
|
196
|
+
cleanupError = candidate;
|
|
197
|
+
}
|
|
198
|
+
const details = {
|
|
199
|
+
cause: error?.code ?? error?.message ?? String(error),
|
|
200
|
+
};
|
|
201
|
+
if (cleanupError !== null) {
|
|
202
|
+
details.cleanup_cause = cleanupError?.code ?? cleanupError?.message ?? String(cleanupError);
|
|
203
|
+
details.leftover_artifact = temporaryPath;
|
|
204
|
+
}
|
|
205
|
+
throw new ReportError('report-write-failed', 'Report publication failed.', details);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function compareProjectedItems(left, right) {
|
|
210
|
+
const leftHasRank = typeof left.fields.rank === 'number' && Number.isFinite(left.fields.rank);
|
|
211
|
+
const rightHasRank = typeof right.fields.rank === 'number' && Number.isFinite(right.fields.rank);
|
|
212
|
+
if (leftHasRank !== rightHasRank) {
|
|
213
|
+
return leftHasRank ? -1 : 1;
|
|
214
|
+
}
|
|
215
|
+
if (leftHasRank) {
|
|
216
|
+
if (left.fields.rank !== right.fields.rank) {
|
|
217
|
+
return left.fields.rank - right.fields.rank;
|
|
218
|
+
}
|
|
219
|
+
const rankedPriority = compareOptionalPriority(left, right);
|
|
220
|
+
if (rankedPriority !== 0) {
|
|
221
|
+
return rankedPriority;
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
const priority = compareOptionalPriority(left, right);
|
|
225
|
+
if (priority !== 0) {
|
|
226
|
+
return priority;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return compareText(left.created, right.created) || compareText(left.id, right.id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function compareOptionalPriority(left, right) {
|
|
233
|
+
const leftHasPriority = typeof left.priority === 'number';
|
|
234
|
+
const rightHasPriority = typeof right.priority === 'number';
|
|
235
|
+
if (leftHasPriority !== rightHasPriority) {
|
|
236
|
+
return leftHasPriority ? -1 : 1;
|
|
237
|
+
}
|
|
238
|
+
return leftHasPriority ? left.priority - right.priority : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function compareText(left, right) {
|
|
242
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function loadReportConfig(ledgerDirectory, outputOverride) {
|
|
246
|
+
const configPath = path.join(ledgerDirectory, '.wowbagger', 'report.json');
|
|
247
|
+
let config;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
config = JSON.parse(await readFile(configPath, 'utf8'));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error?.code === 'ENOENT') {
|
|
253
|
+
throw new ReportError('report-config-invalid', 'Report configuration is missing.');
|
|
254
|
+
}
|
|
255
|
+
if (error instanceof SyntaxError) {
|
|
256
|
+
throw new ReportError('report-config-invalid', 'Report configuration is not valid JSON.');
|
|
257
|
+
}
|
|
258
|
+
throw new ReportError('report-read-failed', 'Report configuration could not be read.', {
|
|
259
|
+
operation: 'read-config',
|
|
260
|
+
path: configPath,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
265
|
+
throw new ReportError('report-config-invalid', 'Report configuration must be a JSON object.');
|
|
266
|
+
}
|
|
267
|
+
const configuredOutputIsValid = isNonEmptyString(config.output);
|
|
268
|
+
if (config.report_version !== 1
|
|
269
|
+
|| !isObject(config.repository)
|
|
270
|
+
|| !isNonEmptyString(config.repository.name)
|
|
271
|
+
|| !isNonEmptyString(config.title)
|
|
272
|
+
|| (config.output !== undefined && !configuredOutputIsValid)
|
|
273
|
+
|| (outputOverride === undefined && !configuredOutputIsValid)
|
|
274
|
+
|| (outputOverride !== undefined && !isNonEmptyString(outputOverride))) {
|
|
275
|
+
throw new ReportError('report-config-invalid', 'Report configuration has missing or invalid required values.');
|
|
276
|
+
}
|
|
277
|
+
if (!hasOnlyKeys(config, CONFIG_KEYS)
|
|
278
|
+
|| !hasOnlyKeys(config.repository, REPOSITORY_KEYS)
|
|
279
|
+
|| (isObject(config.fields) && !hasOnlyKeys(config.fields, FIELD_KEYS))
|
|
280
|
+
|| (isObject(config.swarm) && !hasOnlyKeys(config.swarm, SWARM_KEYS))) {
|
|
281
|
+
throw new ReportError('report-config-invalid', 'Report configuration contains an unknown key.');
|
|
282
|
+
}
|
|
283
|
+
if (config.repository.logo !== undefined
|
|
284
|
+
&& (!isNonEmptyString(config.repository.logo)
|
|
285
|
+
|| !LOGO_MIME_TYPES.has(path.extname(config.repository.logo).toLocaleLowerCase('en-US')))) {
|
|
286
|
+
throw new ReportError('report-config-invalid', 'Report logo must use a supported image extension.');
|
|
287
|
+
}
|
|
288
|
+
if (Object.prototype.hasOwnProperty.call(config, 'fields')
|
|
289
|
+
&& (!isObject(config.fields)
|
|
290
|
+
|| Object.values(config.fields).some((pointer) => !isValidPointer(pointer)))) {
|
|
291
|
+
throw new ReportError('report-config-invalid', 'Report field mappings must be valid RFC 6901 pointers.');
|
|
292
|
+
}
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(config, 'swarm')) {
|
|
294
|
+
const eligibleComplexities = config.swarm?.eligible_complexities;
|
|
295
|
+
if (!isObject(config.swarm)
|
|
296
|
+
|| !Array.isArray(eligibleComplexities)
|
|
297
|
+
|| eligibleComplexities.length === 0
|
|
298
|
+
|| eligibleComplexities.some((value) => !isNonEmptyString(value))
|
|
299
|
+
|| new Set(eligibleComplexities).size !== eligibleComplexities.length
|
|
300
|
+
|| !isValidPointer(config.fields?.area)
|
|
301
|
+
|| !isValidPointer(config.fields?.complexity)) {
|
|
302
|
+
throw new ReportError('report-config-invalid', 'Report swarm configuration is invalid.');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const outputPath = outputOverride === undefined
|
|
306
|
+
? path.resolve(path.dirname(configPath), config.output)
|
|
307
|
+
: path.resolve(process.cwd(), outputOverride);
|
|
308
|
+
await assertReportOutputOutsideLedger(ledgerDirectory, outputPath);
|
|
309
|
+
return {
|
|
310
|
+
reportVersion: config.report_version,
|
|
311
|
+
repository: {
|
|
312
|
+
name: config.repository.name,
|
|
313
|
+
logo: config.repository.logo === undefined
|
|
314
|
+
? null
|
|
315
|
+
: path.resolve(path.dirname(configPath), config.repository.logo),
|
|
316
|
+
},
|
|
317
|
+
title: config.title,
|
|
318
|
+
outputPath,
|
|
319
|
+
fields: config.fields ?? {},
|
|
320
|
+
swarm: config.swarm === undefined
|
|
321
|
+
? null
|
|
322
|
+
: { eligibleComplexities: [...config.swarm.eligible_complexities] },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isObject(value) {
|
|
327
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isNonEmptyString(value) {
|
|
331
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function readLogoDataUrl(logoPath) {
|
|
335
|
+
if (logoPath === null) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const mimeType = LOGO_MIME_TYPES.get(path.extname(logoPath).toLocaleLowerCase('en-US'));
|
|
339
|
+
try {
|
|
340
|
+
const bytes = await readFile(logoPath);
|
|
341
|
+
return `data:${mimeType};base64,${bytes.toString('base64')}`;
|
|
342
|
+
} catch {
|
|
343
|
+
throw new ReportError('report-read-failed', 'Report logo could not be read.', {
|
|
344
|
+
operation: 'read-logo',
|
|
345
|
+
path: logoPath,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hasOnlyKeys(value, allowed) {
|
|
351
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
function isValidPointer(value) {
|
|
356
|
+
return typeof value === 'string'
|
|
357
|
+
&& value.startsWith('/')
|
|
358
|
+
&& !/~(?:[^01]|$)/.test(value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function resolvePhysicalPath(targetPath) {
|
|
362
|
+
let existingPath = targetPath;
|
|
363
|
+
const missingSegments = [];
|
|
364
|
+
|
|
365
|
+
while (true) {
|
|
366
|
+
try {
|
|
367
|
+
return path.join(await realpath(existingPath), ...missingSegments.reverse());
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (error?.code !== 'ENOENT') {
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
const parent = path.dirname(existingPath);
|
|
373
|
+
if (parent === existingPath) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
missingSegments.push(path.basename(existingPath));
|
|
377
|
+
existingPath = parent;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isInside(parentPath, candidatePath) {
|
|
383
|
+
const relative = path.relative(parentPath, candidatePath);
|
|
384
|
+
return relative === ''
|
|
385
|
+
|| (relative !== '..'
|
|
386
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
387
|
+
&& !path.isAbsolute(relative));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function assertReportOutputOutsideLedger(ledgerDirectory, outputPath) {
|
|
391
|
+
const ledgerPath = await realpath(ledgerDirectory);
|
|
392
|
+
const resolvedOutputPath = await resolvePhysicalPath(outputPath);
|
|
393
|
+
if (isInside(ledgerPath, resolvedOutputPath)) {
|
|
394
|
+
throw new ReportError('report-config-invalid', 'Report output must be outside the ledger directory.');
|
|
395
|
+
}
|
|
396
|
+
}
|