qualia-framework 6.6.0 → 6.7.1
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 +3 -3
- package/agents/roadmapper.md +1 -1
- package/bin/command-surface.js +3 -1
- package/bin/state.js +727 -1
- package/docs/onboarding.html +1 -1
- package/guide.md +1 -1
- package/package.json +1 -1
- package/rules/codex-goal.md +1 -1
- package/rules/one-opinion.md +1 -1
- package/skills/qualia/SKILL.md +2 -0
- package/skills/qualia-discuss/SKILL.md +2 -0
- package/skills/qualia-idk/SKILL.md +2 -2
- package/skills/qualia-milestone/SKILL.md +1 -1
- package/skills/qualia-new/SKILL.md +16 -12
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-research/SKILL.md +1 -1
- package/skills/qualia-road/SKILL.md +2 -2
- package/skills/qualia-scope/SKILL.md +86 -4
- package/skills/qualia-ship/SKILL.md +8 -1
- package/templates/CONTEXT.md +1 -1
- package/templates/help.html +1 -1
- package/templates/phase-context.md +1 -1
- package/templates/planning.gitignore +10 -0
- package/templates/project-discovery.md +1 -1
- package/templates/projects/ai-agent.md +2 -2
- package/templates/projects/mobile-app.md +2 -2
- package/templates/projects/voice-agent.md +1 -1
- package/templates/projects/website.md +1 -1
- package/tests/bin.test.sh +8 -8
- package/tests/state.test.sh +176 -0
package/bin/state.js
CHANGED
|
@@ -12,6 +12,21 @@ const TRACKING_FILE = path.join(PLANNING, "tracking.json");
|
|
|
12
12
|
const LOCK_FILE = path.join(PLANNING, ".state.lock");
|
|
13
13
|
const JOURNAL_FILE = path.join(PLANNING, ".state.journal");
|
|
14
14
|
|
|
15
|
+
// ─── A5: per-increment, concurrency-aware layout ────────
|
|
16
|
+
// When `.planning/increments/` exists, the committed source of truth is one
|
|
17
|
+
// file per increment (carrying status + claimed_by + branch). STATE.md and
|
|
18
|
+
// tracking.json become LOCAL (gitignored) generated views, so two people on
|
|
19
|
+
// two branches never conflict on the planning files: they touch different
|
|
20
|
+
// increment files, and the only per-step-mutated files (STATE.md, tracking.json,
|
|
21
|
+
// cursor) are never committed. Projects without `increments/` keep the legacy
|
|
22
|
+
// single-file behavior verbatim — all new behavior is gated on hasIncrementLayout().
|
|
23
|
+
const INCREMENTS_DIR = path.join(PLANNING, "increments");
|
|
24
|
+
const RELEASES_DIR = path.join(PLANNING, "releases");
|
|
25
|
+
const CURSOR_FILE = path.join(PLANNING, ".cursor.json");
|
|
26
|
+
const BACKUP_DIR = path.join(PLANNING, ".backup");
|
|
27
|
+
const GITIGNORE_FILE = path.join(PLANNING, ".gitignore");
|
|
28
|
+
const MIGRATION_MANIFEST = path.join(PLANNING, "migration-manifest.json");
|
|
29
|
+
|
|
15
30
|
// ─── Atomic write (tmp + rename) ─────────────────────────
|
|
16
31
|
// Prevents half-written files when SIGINT, OOM, or AV scanners
|
|
17
32
|
// interrupt mid-write. Same-filesystem rename is atomic on POSIX
|
|
@@ -212,6 +227,467 @@ function writeTracking(t) {
|
|
|
212
227
|
atomicWrite(TRACKING_FILE, JSON.stringify(t, null, 2) + "\n");
|
|
213
228
|
}
|
|
214
229
|
|
|
230
|
+
// ─── A5: increment / release / cursor layer (additive) ───
|
|
231
|
+
// All functions below are pure I/O + render helpers for the per-increment
|
|
232
|
+
// layout. Nothing in the legacy path calls them; they activate only once a
|
|
233
|
+
// project has been migrated (hasIncrementLayout() === true).
|
|
234
|
+
|
|
235
|
+
// Identity for claim ownership. Mirrors state-ledger.js actor() (env-based,
|
|
236
|
+
// not git config) so claimed_by matches the ledger's recorded actor.
|
|
237
|
+
function actor() {
|
|
238
|
+
return (
|
|
239
|
+
process.env.QUALIA_ACTOR ||
|
|
240
|
+
process.env.CLAUDE_USER_NAME ||
|
|
241
|
+
process.env.USER ||
|
|
242
|
+
process.env.USERNAME ||
|
|
243
|
+
"unknown"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hasIncrementLayout() {
|
|
248
|
+
try {
|
|
249
|
+
return fs.existsSync(INCREMENTS_DIR);
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Defense-in-depth: an increment id is interpolated into a file path, so it
|
|
256
|
+
// must never contain path separators or `..`. Reject anything that isn't a
|
|
257
|
+
// plain inc-slug token before it reaches path.join().
|
|
258
|
+
function isValidIncrementId(id) {
|
|
259
|
+
return typeof id === "string" && /^inc-[A-Za-z0-9_-]+$/.test(id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function slugify(name) {
|
|
263
|
+
return String(name || "")
|
|
264
|
+
.toLowerCase()
|
|
265
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
266
|
+
.replace(/^-+|-+$/g, "")
|
|
267
|
+
.slice(0, 40) || "increment";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function incrementId(num, name) {
|
|
271
|
+
return `inc-${String(num).padStart(4, "0")}-${slugify(name)}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Roadmap statuses in legacy STATE.md use a few non-canonical tokens
|
|
275
|
+
// ("ready", "—", "completed"). Normalize to the canonical state-machine
|
|
276
|
+
// statuses so migrated increments transition with the existing VALID_FROM.
|
|
277
|
+
function mapRoadmapStatus(raw) {
|
|
278
|
+
const v = String(raw || "").toLowerCase().trim().replace(/\s+/g, "_");
|
|
279
|
+
if (v === "ready" || v === "—" || v === "-" || v === "" || v === "todo") return "setup";
|
|
280
|
+
if (v === "completed" || v === "complete" || v === "done") return "verified";
|
|
281
|
+
const known = ["setup", "planned", "built", "verified", "polished", "shipped", "handed_off", "failed"];
|
|
282
|
+
return known.includes(v) ? v : "setup";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// CRLF-tolerant frontmatter parser for increment / release files. Returns
|
|
286
|
+
// { fields, body } where fields is a flat key->string map of the `key: value`
|
|
287
|
+
// lines between the leading `---` fences, and body is everything after.
|
|
288
|
+
function parseFrontmatter(content) {
|
|
289
|
+
if (!content) return null;
|
|
290
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
291
|
+
if (!m) return null;
|
|
292
|
+
const fields = {};
|
|
293
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
294
|
+
const kv = line.match(/^([a-zA-Z0-9_]+):\s*(.*?)\r?$/);
|
|
295
|
+
if (kv) fields[kv[1]] = kv[2].trim();
|
|
296
|
+
}
|
|
297
|
+
return { fields, body: m[2] || "" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseIncrementMd(content) {
|
|
301
|
+
const fm = parseFrontmatter(content);
|
|
302
|
+
if (!fm || !fm.fields.id) return null;
|
|
303
|
+
const f = fm.fields;
|
|
304
|
+
const num = parseInt(f.num || f.phase) || 0;
|
|
305
|
+
return {
|
|
306
|
+
id: f.id,
|
|
307
|
+
release: f.release || "r1",
|
|
308
|
+
num,
|
|
309
|
+
phase: num, // phase == num: the original roadmap index, kept for back-compat
|
|
310
|
+
title: f.title || "",
|
|
311
|
+
goal: f.goal || "",
|
|
312
|
+
archetype: f.archetype || "",
|
|
313
|
+
status: f.status || "setup",
|
|
314
|
+
claimed_by: f.claimed_by || "",
|
|
315
|
+
branch: f.branch || "",
|
|
316
|
+
verification: f.verification || "pending",
|
|
317
|
+
gap_cycles: parseInt(f.gap_cycles) || 0,
|
|
318
|
+
tasks_done: parseInt(f.tasks_done) || 0,
|
|
319
|
+
tasks_total: parseInt(f.tasks_total) || 0,
|
|
320
|
+
wave: parseInt(f.wave) || 0,
|
|
321
|
+
build_count: parseInt(f.build_count) || 0,
|
|
322
|
+
deploy_count: parseInt(f.deploy_count) || 0,
|
|
323
|
+
deployed_url: f.deployed_url || "",
|
|
324
|
+
body: fm.body,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderIncrementMd(inc) {
|
|
329
|
+
const fm = [
|
|
330
|
+
`id: ${inc.id}`,
|
|
331
|
+
`release: ${inc.release || "r1"}`,
|
|
332
|
+
`num: ${inc.num}`,
|
|
333
|
+
`title: ${inc.title || ""}`,
|
|
334
|
+
`goal: ${inc.goal || ""}`,
|
|
335
|
+
`archetype: ${inc.archetype || ""}`,
|
|
336
|
+
`status: ${inc.status || "setup"}`,
|
|
337
|
+
`claimed_by: ${inc.claimed_by || ""}`,
|
|
338
|
+
`branch: ${inc.branch || ""}`,
|
|
339
|
+
`verification: ${inc.verification || "pending"}`,
|
|
340
|
+
`gap_cycles: ${inc.gap_cycles || 0}`,
|
|
341
|
+
`tasks_done: ${inc.tasks_done || 0}`,
|
|
342
|
+
`tasks_total: ${inc.tasks_total || 0}`,
|
|
343
|
+
`wave: ${inc.wave || 0}`,
|
|
344
|
+
`build_count: ${inc.build_count || 0}`,
|
|
345
|
+
`deploy_count: ${inc.deploy_count || 0}`,
|
|
346
|
+
`deployed_url: ${inc.deployed_url || ""}`,
|
|
347
|
+
].join("\n");
|
|
348
|
+
// Preserve an existing body verbatim (round-trip safe); seed a default body
|
|
349
|
+
// with a DoD section the first time an increment is materialized.
|
|
350
|
+
const body = (inc.body && inc.body.trim())
|
|
351
|
+
? inc.body.replace(/^\n+/, "")
|
|
352
|
+
: `# ${inc.title || inc.id}\n\nGoal: ${inc.goal || "TBD"}\n\n## Acceptance Criteria\n- TBD\n\n## Definition of Done\n- [ ] TBD\n`;
|
|
353
|
+
return `---\n${fm}\n---\n\n${body}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function incrementPath(id) {
|
|
357
|
+
return path.join(INCREMENTS_DIR, `${id}.md`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readIncrement(id) {
|
|
361
|
+
try {
|
|
362
|
+
return parseIncrementMd(fs.readFileSync(incrementPath(id), "utf8"));
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeIncrement(inc) {
|
|
369
|
+
if (!fs.existsSync(INCREMENTS_DIR)) fs.mkdirSync(INCREMENTS_DIR, { recursive: true });
|
|
370
|
+
atomicWrite(incrementPath(inc.id), renderIncrementMd(inc));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function listIncrements() {
|
|
374
|
+
try {
|
|
375
|
+
return fs
|
|
376
|
+
.readdirSync(INCREMENTS_DIR)
|
|
377
|
+
.filter((f) => f.endsWith(".md"))
|
|
378
|
+
.map((f) => readIncrement(f.replace(/\.md$/, "")))
|
|
379
|
+
.filter(Boolean)
|
|
380
|
+
.sort((a, b) => a.num - b.num);
|
|
381
|
+
} catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Definition-of-Done gate input: scan an increment body for open checklist
|
|
387
|
+
// lines. A line `- [ ] ...` is OPEN unless it carries a `WAIVED:` marker.
|
|
388
|
+
function incrementOpenDoD(inc) {
|
|
389
|
+
if (!inc || !inc.body) return [];
|
|
390
|
+
const open = [];
|
|
391
|
+
for (const line of inc.body.split(/\r?\n/)) {
|
|
392
|
+
const m = line.match(/^\s*[-*]\s*\[\s\]\s*(.+?)\s*$/);
|
|
393
|
+
if (m && !/WAIVED:/i.test(line)) open.push(m[1]);
|
|
394
|
+
}
|
|
395
|
+
return open;
|
|
396
|
+
}
|
|
397
|
+
function incrementWaivedDoD(inc) {
|
|
398
|
+
if (!inc || !inc.body) return [];
|
|
399
|
+
const waived = [];
|
|
400
|
+
for (const line of inc.body.split(/\r?\n/)) {
|
|
401
|
+
if (/^\s*[-*]\s*\[\s\]/.test(line) && /WAIVED:/i.test(line)) waived.push(line.trim());
|
|
402
|
+
}
|
|
403
|
+
return waived;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Release files ──────────────────────────────────────
|
|
407
|
+
function parseReleaseMd(content) {
|
|
408
|
+
const fm = parseFrontmatter(content);
|
|
409
|
+
if (!fm || !fm.fields.id) return null;
|
|
410
|
+
const incs = [];
|
|
411
|
+
for (const line of fm.body.split(/\r?\n/)) {
|
|
412
|
+
const m = line.match(/^\s*[-*]\s*(inc-[a-z0-9-]+)\s*$/i);
|
|
413
|
+
if (m) incs.push(m[1]);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
id: fm.fields.id,
|
|
417
|
+
title: fm.fields.title || "",
|
|
418
|
+
type: fm.fields.type || "rolling",
|
|
419
|
+
status: fm.fields.status || "active",
|
|
420
|
+
increments: incs,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function renderReleaseMd(rel) {
|
|
424
|
+
const list = (rel.increments || []).map((i) => `- ${i}`).join("\n");
|
|
425
|
+
return `---\nid: ${rel.id}\ntitle: ${rel.title || ""}\ntype: ${rel.type || "rolling"}\nstatus: ${rel.status || "active"}\n---\n\n## Increments\n${list}\n`;
|
|
426
|
+
}
|
|
427
|
+
function releasePath(rid) {
|
|
428
|
+
return path.join(RELEASES_DIR, `${rid}.md`);
|
|
429
|
+
}
|
|
430
|
+
function readRelease(rid) {
|
|
431
|
+
try {
|
|
432
|
+
return parseReleaseMd(fs.readFileSync(releasePath(rid), "utf8"));
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function writeRelease(rel) {
|
|
438
|
+
if (!fs.existsSync(RELEASES_DIR)) fs.mkdirSync(RELEASES_DIR, { recursive: true });
|
|
439
|
+
atomicWrite(releasePath(rel.id), renderReleaseMd(rel));
|
|
440
|
+
}
|
|
441
|
+
function listReleases() {
|
|
442
|
+
try {
|
|
443
|
+
return fs
|
|
444
|
+
.readdirSync(RELEASES_DIR)
|
|
445
|
+
.filter((f) => f.endsWith(".md"))
|
|
446
|
+
.map((f) => readRelease(f.replace(/\.md$/, "")))
|
|
447
|
+
.filter(Boolean);
|
|
448
|
+
} catch {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ─── Local cursor (never committed) ─────────────────────
|
|
454
|
+
function readCursor() {
|
|
455
|
+
try {
|
|
456
|
+
return JSON.parse(fs.readFileSync(CURSOR_FILE, "utf8"));
|
|
457
|
+
} catch {
|
|
458
|
+
return {};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function writeCursor(c) {
|
|
462
|
+
atomicWrite(CURSOR_FILE, JSON.stringify(c, null, 2) + "\n");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const A5_DONE_STATUSES = new Set(["verified", "polished", "shipped", "handed_off"]);
|
|
466
|
+
|
|
467
|
+
// The "active" increment for dashboard/tracking mirroring: explicit cursor,
|
|
468
|
+
// else the first not-yet-done increment, else the last.
|
|
469
|
+
function activeIncrement(increments, cursor) {
|
|
470
|
+
if (!increments.length) return null;
|
|
471
|
+
const cur = cursor && cursor.current_increment;
|
|
472
|
+
if (cur) {
|
|
473
|
+
const hit = increments.find((i) => i.id === cur);
|
|
474
|
+
if (hit) return hit;
|
|
475
|
+
}
|
|
476
|
+
return increments.find((i) => !A5_DONE_STATUSES.has(i.status)) || increments[increments.length - 1];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Build the writeStateMd() input object from increments — STATE.md becomes a
|
|
480
|
+
// pure projection of the committed increment files.
|
|
481
|
+
function dashboardStateFromIncrements(increments, active, t) {
|
|
482
|
+
return {
|
|
483
|
+
phase: active ? active.num : 1,
|
|
484
|
+
total_phases: increments.length || 1,
|
|
485
|
+
phase_name: active ? active.title : "",
|
|
486
|
+
status: active ? active.status : "setup",
|
|
487
|
+
assigned_to: active ? active.claimed_by : "",
|
|
488
|
+
profile: (t && t.profile) || "strict",
|
|
489
|
+
verification: active ? active.verification : "pending",
|
|
490
|
+
last_activity: (t && t.last_activity) || "State regenerated from increments",
|
|
491
|
+
phases: increments.map((i) => ({ num: i.num, name: i.title, goal: i.goal, status: i.status })),
|
|
492
|
+
blockers: t && Array.isArray(t.blockers) && t.blockers.length ? t.blockers.join("; ") : "None.",
|
|
493
|
+
resume: (t && t.resume) || "—",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Regenerate the two LOCAL views (tracking.json + STATE.md) from the committed
|
|
498
|
+
// increment files. tracking.json stays FAT (mirrors the active increment +
|
|
499
|
+
// keeps identity/lifetime) so all back-compat consumers keep working; it gains
|
|
500
|
+
// a per-increment index + releases index + mode for the ERP. Idempotent.
|
|
501
|
+
function regenerateViews(activity) {
|
|
502
|
+
const increments = listIncrements();
|
|
503
|
+
const cursor = readCursor();
|
|
504
|
+
const active = activeIncrement(increments, cursor);
|
|
505
|
+
const t = ensureLifetime(readTracking() || {});
|
|
506
|
+
if (activity) t.last_activity = activity;
|
|
507
|
+
|
|
508
|
+
// Mirror the active increment's per-phase fields into tracking for consumers.
|
|
509
|
+
if (active) {
|
|
510
|
+
t.phase = active.num;
|
|
511
|
+
t.phase_name = active.title;
|
|
512
|
+
t.status = active.status;
|
|
513
|
+
t.verification = active.verification;
|
|
514
|
+
t.tasks_done = active.tasks_done;
|
|
515
|
+
t.tasks_total = active.tasks_total;
|
|
516
|
+
t.wave = active.wave;
|
|
517
|
+
t.build_count = active.build_count;
|
|
518
|
+
t.deploy_count = active.deploy_count;
|
|
519
|
+
t.deployed_url = active.deployed_url || t.deployed_url || "";
|
|
520
|
+
t.gap_cycles = t.gap_cycles || {};
|
|
521
|
+
t.gap_cycles[String(active.num)] = active.gap_cycles;
|
|
522
|
+
}
|
|
523
|
+
t.total_phases = increments.length;
|
|
524
|
+
t.mode = t.mode || (listReleases()[0] ? listReleases()[0].type : "rolling");
|
|
525
|
+
// Lightweight indexes for the ERP / dashboards (derived, not authoritative).
|
|
526
|
+
t.increments = increments.map((i) => ({
|
|
527
|
+
id: i.id, num: i.num, title: i.title, status: i.status,
|
|
528
|
+
claimed_by: i.claimed_by, branch: i.branch, verification: i.verification,
|
|
529
|
+
}));
|
|
530
|
+
t.releases = listReleases().map((r) => ({ id: r.id, type: r.type, status: r.status, increments: r.increments }));
|
|
531
|
+
t.next_command = active
|
|
532
|
+
? nextCommand(active.status, active.num, increments.length, active.verification)
|
|
533
|
+
: "/qualia";
|
|
534
|
+
t.last_updated = new Date().toISOString();
|
|
535
|
+
writeTracking(t);
|
|
536
|
+
|
|
537
|
+
// STATE.md dashboard (gitignored projection).
|
|
538
|
+
writeStateMd(dashboardStateFromIncrements(increments, active, t));
|
|
539
|
+
return { increments, active, tracking: t };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── A5: increment-aware check (read-only, concurrency router) ───
|
|
543
|
+
// Returns the response shape legacy consumers expect PLUS the concurrency
|
|
544
|
+
// fields: my_claim, next_increment, claimed_increments[]. It never writes, so
|
|
545
|
+
// it's safe inside the /qualia parallel Bash batch and never conflicts.
|
|
546
|
+
function cmdCheckIncrement(opts) {
|
|
547
|
+
const me = actor();
|
|
548
|
+
const increments = listIncrements();
|
|
549
|
+
const t = ensureLifetime(readTracking() || {});
|
|
550
|
+
if (!increments.length) {
|
|
551
|
+
return output({ ok: false, error: "NO_INCREMENTS", message: "increments/ exists but is empty. Run `state.js migrate` or `state.js fix`." });
|
|
552
|
+
}
|
|
553
|
+
const isDone = (i) => A5_DONE_STATUSES.has(i.status);
|
|
554
|
+
const claimed_increments = increments
|
|
555
|
+
.filter((i) => i.claimed_by && i.claimed_by !== me && !isDone(i))
|
|
556
|
+
.map((i) => ({ id: i.id, num: i.num, title: i.title, status: i.status, claimed_by: i.claimed_by, branch: i.branch }));
|
|
557
|
+
const mine = increments.find((i) => i.claimed_by === me && !isDone(i));
|
|
558
|
+
const next_unclaimed = increments.find((i) => !isDone(i) && (!i.claimed_by || i.claimed_by === me));
|
|
559
|
+
const focus = mine || next_unclaimed || activeIncrement(increments, readCursor());
|
|
560
|
+
const totalPhases = increments.length;
|
|
561
|
+
const allDone = increments.every(isDone);
|
|
562
|
+
const next_command = mine
|
|
563
|
+
? nextCommand(mine.status, mine.num, totalPhases, mine.verification)
|
|
564
|
+
: next_unclaimed
|
|
565
|
+
? nextCommand(next_unclaimed.status, next_unclaimed.num, totalPhases, next_unclaimed.verification)
|
|
566
|
+
: (allDone ? "/qualia-polish" : "/qualia");
|
|
567
|
+
|
|
568
|
+
output({
|
|
569
|
+
ok: true,
|
|
570
|
+
layout: "increments",
|
|
571
|
+
phase: focus ? focus.num : 1,
|
|
572
|
+
phase_name: focus ? focus.title : "",
|
|
573
|
+
total_phases: totalPhases,
|
|
574
|
+
status: focus ? focus.status : "setup",
|
|
575
|
+
assigned_to: focus ? focus.claimed_by : "",
|
|
576
|
+
profile: resolveProfile(null, t),
|
|
577
|
+
milestone: t.milestone || 1,
|
|
578
|
+
milestone_name: t.milestone_name || "",
|
|
579
|
+
milestones: t.milestones || [],
|
|
580
|
+
lifetime: t.lifetime,
|
|
581
|
+
verification: focus ? focus.verification : "pending",
|
|
582
|
+
gap_cycles: focus ? focus.gap_cycles : 0,
|
|
583
|
+
gap_cycle_limit: getGapCycleLimit(),
|
|
584
|
+
tasks_done: focus ? focus.tasks_done : 0,
|
|
585
|
+
tasks_total: focus ? focus.tasks_total : 0,
|
|
586
|
+
deployed_url: focus ? focus.deployed_url : "",
|
|
587
|
+
actor: me,
|
|
588
|
+
my_claim: mine ? mine.id : "",
|
|
589
|
+
next_increment: next_unclaimed ? next_unclaimed.id : "",
|
|
590
|
+
claimed_increments,
|
|
591
|
+
increments: increments.map((i) => ({ id: i.id, num: i.num, title: i.title, status: i.status, claimed_by: i.claimed_by, branch: i.branch, verification: i.verification })),
|
|
592
|
+
next_command,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ─── A5: increment-aware transition ─────────────────────
|
|
597
|
+
// Operates on ONE increment file (by --id or cursor). The committed mutation
|
|
598
|
+
// is a single increment file; tracking.json + STATE.md are regenerated locally.
|
|
599
|
+
function cmdTransitionIncrement(opts, target) {
|
|
600
|
+
const increments = listIncrements();
|
|
601
|
+
|
|
602
|
+
// note/activity short-circuit — log to (local) tracking + regenerate.
|
|
603
|
+
if (target === "note" || target === "activity") {
|
|
604
|
+
const t = ensureLifetime(readTracking() || {});
|
|
605
|
+
if (opts.notes) t.notes = opts.notes;
|
|
606
|
+
if (opts.tasks_done) {
|
|
607
|
+
const c = parseInt(opts.tasks_done) || 0;
|
|
608
|
+
if (c > 0) t.lifetime.tasks_completed += c;
|
|
609
|
+
}
|
|
610
|
+
writeTracking(t);
|
|
611
|
+
regenerateViews(opts.notes || "Activity logged");
|
|
612
|
+
return output({ ok: true, action: target, layout: "increments" });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Resolve the target increment: explicit --id, else --phase N (back-compat
|
|
616
|
+
// with phase-addressed skills: maps to the increment whose num === N), else
|
|
617
|
+
// the cursor, else the active increment.
|
|
618
|
+
if (opts.id && !isValidIncrementId(opts.id)) return output(fail("INVALID_ID", `Invalid increment id '${opts.id}' (must match inc-<slug>).`));
|
|
619
|
+
const byPhase = opts.phase ? (increments.find((i) => i.num === parseInt(opts.phase)) || {}).id : "";
|
|
620
|
+
const id = opts.id || byPhase || readCursor().current_increment || "";
|
|
621
|
+
const inc = id ? readIncrement(id) : activeIncrement(increments, readCursor());
|
|
622
|
+
if (!inc) return output(fail("UNKNOWN_INCREMENT", id ? `No increment '${id}' under ${INCREMENTS_DIR}` : "No active increment — set the cursor or pass --id."));
|
|
623
|
+
|
|
624
|
+
const phase = inc.num;
|
|
625
|
+
const prevStatus = inc.status;
|
|
626
|
+
const check = checkPreconditions({ phase, status: inc.status, total_phases: increments.length }, target, { ...opts, phase });
|
|
627
|
+
if (!check.ok) {
|
|
628
|
+
const forceable = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT", "INVALID_PLAN"];
|
|
629
|
+
if (opts.force && forceable.includes(check.error)) {
|
|
630
|
+
console.error(`WARNING: Forcing transition despite: ${check.message}`);
|
|
631
|
+
} else {
|
|
632
|
+
return output(check);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const t = ensureLifetime(readTracking() || {});
|
|
637
|
+
inc.status = target;
|
|
638
|
+
if (target === "planned") {
|
|
639
|
+
if (prevStatus === "verified") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
|
|
640
|
+
} else if (target === "built") {
|
|
641
|
+
inc.tasks_done = parseInt(opts.tasks_done) || 0;
|
|
642
|
+
inc.tasks_total = parseInt(opts.tasks_total) || 0;
|
|
643
|
+
inc.wave = parseInt(opts.wave) || 0;
|
|
644
|
+
inc.build_count = (inc.build_count || 0) + 1;
|
|
645
|
+
} else if (target === "verified") {
|
|
646
|
+
inc.verification = opts.verification;
|
|
647
|
+
if (opts.verification !== "pass") {
|
|
648
|
+
inc.status = "failed";
|
|
649
|
+
} else {
|
|
650
|
+
t.lifetime.tasks_completed += inc.tasks_done || 0;
|
|
651
|
+
t.lifetime.phases_completed += 1;
|
|
652
|
+
inc.gap_cycles = 0;
|
|
653
|
+
// Auto-advance the cursor to the next increment on a pass.
|
|
654
|
+
const idx = increments.findIndex((i) => i.id === inc.id);
|
|
655
|
+
const nextInc = increments[idx + 1];
|
|
656
|
+
if (nextInc) writeCursor({ current_increment: nextInc.id });
|
|
657
|
+
}
|
|
658
|
+
} else if (target === "shipped") {
|
|
659
|
+
inc.deployed_url = opts.deployed_url || "";
|
|
660
|
+
inc.deploy_count = (inc.deploy_count || 0) + 1;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
writeIncrement(inc);
|
|
664
|
+
writeTracking(t); // persist lifetime bump before regenerate re-reads tracking
|
|
665
|
+
regenerateViews(`${target} (${inc.id})`);
|
|
666
|
+
|
|
667
|
+
const ledger = recordLedgerEvent({ action: target, phase_before: phase, phase_after: inc.num, status_before: prevStatus, status_after: inc.status });
|
|
668
|
+
_trace("state-transition", "allow", { layout: "increments", id: inc.id, status: inc.status, previous_status: prevStatus, verification: inc.verification });
|
|
669
|
+
|
|
670
|
+
const result = {
|
|
671
|
+
ok: true,
|
|
672
|
+
layout: "increments",
|
|
673
|
+
id: inc.id,
|
|
674
|
+
phase: inc.num,
|
|
675
|
+
phase_name: inc.title,
|
|
676
|
+
status: inc.status,
|
|
677
|
+
previous_status: prevStatus,
|
|
678
|
+
verification: inc.verification,
|
|
679
|
+
gap_cycles: inc.gap_cycles || 0,
|
|
680
|
+
next_command: nextCommand(inc.status, inc.num, increments.length, inc.verification),
|
|
681
|
+
};
|
|
682
|
+
if (ledger.ok) {
|
|
683
|
+
result.ledger_event_id = ledger.event_id;
|
|
684
|
+
result.ledger_event_hash = ledger.event_hash;
|
|
685
|
+
} else if (ledger.error) {
|
|
686
|
+
result.ledger_error = ledger.error;
|
|
687
|
+
}
|
|
688
|
+
output(result);
|
|
689
|
+
}
|
|
690
|
+
|
|
215
691
|
// Ensure lifetime + milestone fields exist (backward compat for old tracking files)
|
|
216
692
|
function ensureLifetime(t) {
|
|
217
693
|
if (!t) return t;
|
|
@@ -602,6 +1078,8 @@ function resolveProfile(s, t) {
|
|
|
602
1078
|
}
|
|
603
1079
|
|
|
604
1080
|
function cmdCheck(opts) {
|
|
1081
|
+
// A5: migrated projects route through the concurrency-aware, read-only check.
|
|
1082
|
+
if (hasIncrementLayout()) return cmdCheckIncrement(opts);
|
|
605
1083
|
const t = readTracking();
|
|
606
1084
|
const s = parseStateMd(readState());
|
|
607
1085
|
// True NO_PROJECT only when BOTH the durable tracking AND the dashboard are
|
|
@@ -844,6 +1322,9 @@ function cmdTransition(opts) {
|
|
|
844
1322
|
const target = opts.to;
|
|
845
1323
|
if (!target) return output(fail("MISSING_ARG", "--to is required"));
|
|
846
1324
|
|
|
1325
|
+
// A5: migrated projects transition per-increment (one committed file).
|
|
1326
|
+
if (hasIncrementLayout()) return cmdTransitionIncrement(opts, target);
|
|
1327
|
+
|
|
847
1328
|
const beforeStateRaw = readState();
|
|
848
1329
|
const beforeTrackingRaw = readTrackingRaw();
|
|
849
1330
|
const t = parseTrackingRaw(beforeTrackingRaw);
|
|
@@ -1253,6 +1734,242 @@ function cmdFix(opts) {
|
|
|
1253
1734
|
output(result);
|
|
1254
1735
|
}
|
|
1255
1736
|
|
|
1737
|
+
// ─── A5: .planning/.gitignore ───────────────────────────
|
|
1738
|
+
// The structural fix: the only files mutated every step (STATE.md, tracking.json,
|
|
1739
|
+
// cursor) are LOCAL; only increment/release files are committed. This gitignore
|
|
1740
|
+
// enforces that. Kept in sync with templates/planning.gitignore.
|
|
1741
|
+
const PLANNING_GITIGNORE = [
|
|
1742
|
+
"# A5: per-step-mutated planning state is LOCAL (generated views), never committed.",
|
|
1743
|
+
"# Only increments/ and releases/ are the durable, committed source of truth —",
|
|
1744
|
+
"# so two people on two branches never conflict on the planning files.",
|
|
1745
|
+
"STATE.md",
|
|
1746
|
+
"tracking.json",
|
|
1747
|
+
".cursor.json",
|
|
1748
|
+
".state.lock",
|
|
1749
|
+
".state.journal",
|
|
1750
|
+
".backup/",
|
|
1751
|
+
"migration-manifest.json",
|
|
1752
|
+
"",
|
|
1753
|
+
].join("\n");
|
|
1754
|
+
|
|
1755
|
+
function writePlanningGitignore() {
|
|
1756
|
+
atomicWrite(GITIGNORE_FILE, PLANNING_GITIGNORE);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// ─── A5: non-destructive migration ──────────────────────
|
|
1760
|
+
// Converts a legacy single-file project (STATE.md + tracking.json) to the
|
|
1761
|
+
// per-increment layout. Backs up both files first, writes a reversible
|
|
1762
|
+
// manifest, is idempotent (re-run is a no-op once increments/ exists), and
|
|
1763
|
+
// supports `migrate --revert` to restore byte-identical originals.
|
|
1764
|
+
function cmdMigrate(opts) {
|
|
1765
|
+
if (opts.revert) return cmdMigrateRevert(opts);
|
|
1766
|
+
|
|
1767
|
+
// Idempotent guard: already migrated → no-op.
|
|
1768
|
+
if (hasIncrementLayout()) {
|
|
1769
|
+
return output({ ok: true, action: "migrate", already_migrated: true, message: "increments/ already exists — nothing to do." });
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const stateRaw = readState();
|
|
1773
|
+
const trackingRaw = readTrackingRaw();
|
|
1774
|
+
if (!stateRaw && !trackingRaw) {
|
|
1775
|
+
return output(fail("NO_PROJECT", "No .planning/ found. Nothing to migrate."));
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const s = parseStateMd(stateRaw);
|
|
1779
|
+
const t = ensureLifetime(readTracking() || {});
|
|
1780
|
+
if (!s || !s.phases || !s.phases.length) {
|
|
1781
|
+
return output(fail("MIGRATE_NO_ROADMAP", "Could not parse a roadmap from STATE.md. Run `state.js fix` first, then migrate."));
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1785
|
+
const created = [];
|
|
1786
|
+
let ids = [];
|
|
1787
|
+
|
|
1788
|
+
try {
|
|
1789
|
+
// 1. Backup originals.
|
|
1790
|
+
if (!fs.existsSync(BACKUP_DIR)) { fs.mkdirSync(BACKUP_DIR, { recursive: true }); created.push(BACKUP_DIR); }
|
|
1791
|
+
const backupState = stateRaw != null ? path.join(BACKUP_DIR, `STATE.md.${stamp}`) : null;
|
|
1792
|
+
const backupTracking = trackingRaw != null ? path.join(BACKUP_DIR, `tracking.json.${stamp}`) : null;
|
|
1793
|
+
if (backupState) atomicWrite(backupState, stateRaw);
|
|
1794
|
+
if (backupTracking) atomicWrite(backupTracking, trackingRaw);
|
|
1795
|
+
|
|
1796
|
+
// 2. One increment file per roadmap phase. The CURRENT phase carries the
|
|
1797
|
+
// live status/verification from tracking; others use the roadmap row.
|
|
1798
|
+
const curPhase = Number(t.phase || s.phase) || s.phase;
|
|
1799
|
+
for (const p of s.phases) {
|
|
1800
|
+
const id = incrementId(p.num, p.name);
|
|
1801
|
+
const isCurrent = p.num === curPhase;
|
|
1802
|
+
const status = isCurrent
|
|
1803
|
+
? mapRoadmapStatus(t.status || s.status || p.status)
|
|
1804
|
+
: mapRoadmapStatus(p.status);
|
|
1805
|
+
const inc = {
|
|
1806
|
+
id,
|
|
1807
|
+
release: "r1",
|
|
1808
|
+
num: p.num,
|
|
1809
|
+
title: p.name,
|
|
1810
|
+
goal: p.goal || "",
|
|
1811
|
+
archetype: t.type || "",
|
|
1812
|
+
status,
|
|
1813
|
+
claimed_by: "",
|
|
1814
|
+
branch: "",
|
|
1815
|
+
verification: isCurrent ? (t.verification || "pending") : "pending",
|
|
1816
|
+
gap_cycles: (t.gap_cycles || {})[String(p.num)] || 0,
|
|
1817
|
+
tasks_done: isCurrent ? (t.tasks_done || 0) : 0,
|
|
1818
|
+
tasks_total: isCurrent ? (t.tasks_total || 0) : 0,
|
|
1819
|
+
wave: isCurrent ? (t.wave || 0) : 0,
|
|
1820
|
+
build_count: isCurrent ? (t.build_count || 0) : 0,
|
|
1821
|
+
deploy_count: isCurrent ? (t.deploy_count || 0) : 0,
|
|
1822
|
+
deployed_url: isCurrent ? (t.deployed_url || "") : "",
|
|
1823
|
+
// Legacy increments carry a satisfied DoD line so the ship gate never
|
|
1824
|
+
// retroactively traps a phase that predates A5's DoD tracking.
|
|
1825
|
+
body: `# ${p.name}\n\nGoal: ${p.goal || "TBD"}\n\n## Acceptance Criteria\n- (migrated from legacy roadmap)\n\n## Definition of Done\n- [x] Legacy increment migrated from STATE.md roadmap (pre-A5 — DoD not retroactively tracked)\n`,
|
|
1826
|
+
};
|
|
1827
|
+
writeIncrement(inc);
|
|
1828
|
+
ids.push(id);
|
|
1829
|
+
created.push(incrementPath(id));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// 3. Wrap all increments in a single rolling release.
|
|
1833
|
+
const rel = { id: "r1", title: t.milestone_name || "Release 1", type: t.mode || "rolling", status: "active", increments: ids };
|
|
1834
|
+
writeRelease(rel);
|
|
1835
|
+
created.push(releasePath("r1"));
|
|
1836
|
+
|
|
1837
|
+
// 4. Cursor points at the current phase's increment.
|
|
1838
|
+
const curId = ids[curPhase - 1] || ids[0];
|
|
1839
|
+
writeCursor({ current_increment: curId });
|
|
1840
|
+
created.push(CURSOR_FILE);
|
|
1841
|
+
|
|
1842
|
+
// 5. gitignore the now-local views.
|
|
1843
|
+
writePlanningGitignore();
|
|
1844
|
+
created.push(GITIGNORE_FILE);
|
|
1845
|
+
|
|
1846
|
+
// 6. Manifest (reversible).
|
|
1847
|
+
const manifest = {
|
|
1848
|
+
migrated_at: new Date().toISOString(),
|
|
1849
|
+
schema: "a5-increments-v1",
|
|
1850
|
+
backup: { state: backupState, tracking: backupTracking },
|
|
1851
|
+
created,
|
|
1852
|
+
increment_ids: ids,
|
|
1853
|
+
cursor: curId,
|
|
1854
|
+
};
|
|
1855
|
+
atomicWrite(MIGRATION_MANIFEST, JSON.stringify(manifest, null, 2) + "\n");
|
|
1856
|
+
|
|
1857
|
+
// 7. Regenerate the local views (tracking index + STATE.md) from increments.
|
|
1858
|
+
regenerateViews();
|
|
1859
|
+
} catch (e) {
|
|
1860
|
+
return output(fail("MIGRATE_WRITE_ERROR", e.message));
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
recordLedgerEvent({ action: "migrate", phase_before: s.phase, phase_after: s.phase, status_before: s.status, status_after: s.status });
|
|
1864
|
+
|
|
1865
|
+
output({
|
|
1866
|
+
ok: true,
|
|
1867
|
+
action: "migrate",
|
|
1868
|
+
increments: created.filter((c) => c.includes("increments")).length,
|
|
1869
|
+
increment_ids: ids,
|
|
1870
|
+
cursor: ids[(Number(t.phase || s.phase) || s.phase) - 1] || ids[0],
|
|
1871
|
+
note: "STATE.md + tracking.json are now gitignored generated views. If they were previously committed, run `git rm --cached .planning/STATE.md .planning/tracking.json` to stop tracking them. Revert anytime with `state.js migrate --revert`.",
|
|
1872
|
+
next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification),
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function cmdMigrateRevert(opts) {
|
|
1877
|
+
let manifest;
|
|
1878
|
+
try {
|
|
1879
|
+
manifest = JSON.parse(fs.readFileSync(MIGRATION_MANIFEST, "utf8"));
|
|
1880
|
+
} catch {
|
|
1881
|
+
return output(fail("NO_MANIFEST", "No migration-manifest.json found — nothing to revert."));
|
|
1882
|
+
}
|
|
1883
|
+
try {
|
|
1884
|
+
// Restore originals byte-identically.
|
|
1885
|
+
if (manifest.backup && manifest.backup.state && fs.existsSync(manifest.backup.state)) {
|
|
1886
|
+
atomicWrite(STATE_FILE, fs.readFileSync(manifest.backup.state, "utf8"));
|
|
1887
|
+
}
|
|
1888
|
+
if (manifest.backup && manifest.backup.tracking && fs.existsSync(manifest.backup.tracking)) {
|
|
1889
|
+
atomicWrite(TRACKING_FILE, fs.readFileSync(manifest.backup.tracking, "utf8"));
|
|
1890
|
+
}
|
|
1891
|
+
// Remove generated layout.
|
|
1892
|
+
for (const dir of [INCREMENTS_DIR, RELEASES_DIR]) {
|
|
1893
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
1894
|
+
}
|
|
1895
|
+
for (const f of [CURSOR_FILE, GITIGNORE_FILE, MIGRATION_MANIFEST]) {
|
|
1896
|
+
try { fs.unlinkSync(f); } catch {}
|
|
1897
|
+
}
|
|
1898
|
+
} catch (e) {
|
|
1899
|
+
return output(fail("REVERT_ERROR", e.message));
|
|
1900
|
+
}
|
|
1901
|
+
output({ ok: true, action: "migrate-revert", restored: true, message: "Restored STATE.md + tracking.json from backup; removed increments/, releases/, cursor, gitignore, manifest." });
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// ─── A5: claim an increment for exclusive work ──────────
|
|
1905
|
+
// Writes claimed_by (this actor) + branch onto ONE increment file — committed
|
|
1906
|
+
// on the feature branch. A second actor claiming a held increment is refused.
|
|
1907
|
+
function cmdClaim(opts) {
|
|
1908
|
+
if (!hasIncrementLayout()) {
|
|
1909
|
+
return output(fail("NOT_MIGRATED", "No increments/ — run `state.js migrate` first."));
|
|
1910
|
+
}
|
|
1911
|
+
const id = opts.id || (readCursor().current_increment || "");
|
|
1912
|
+
if (!id) return output(fail("MISSING_ARG", "--id is required (or set the cursor)"));
|
|
1913
|
+
if (!isValidIncrementId(id)) return output(fail("INVALID_ID", `Invalid increment id '${id}' (must match inc-<slug>).`));
|
|
1914
|
+
const inc = readIncrement(id);
|
|
1915
|
+
if (!inc) return output(fail("UNKNOWN_INCREMENT", `No increment '${id}' under ${INCREMENTS_DIR}`));
|
|
1916
|
+
|
|
1917
|
+
const me = actor();
|
|
1918
|
+
if (inc.claimed_by && inc.claimed_by !== me && !opts.force) {
|
|
1919
|
+
return output(fail("ALREADY_CLAIMED", `Increment '${id}' is held by '${inc.claimed_by}' (branch '${inc.branch}'). Use --force to steal.`));
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
inc.claimed_by = me;
|
|
1923
|
+
inc.branch = opts.branch || inc.branch || "";
|
|
1924
|
+
writeIncrement(inc);
|
|
1925
|
+
writeCursor({ current_increment: id });
|
|
1926
|
+
regenerateViews(`Claimed ${id} (${me})`);
|
|
1927
|
+
recordLedgerEvent({ action: "claim", phase_before: inc.num, phase_after: inc.num, status_before: inc.status, status_after: inc.status });
|
|
1928
|
+
output({ ok: true, action: "claim", id, claimed_by: me, branch: inc.branch, status: inc.status, next_command: nextCommand(inc.status, inc.num, listIncrements().length, inc.verification) });
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ─── A5: release (ship) an increment + clear its claim ──
|
|
1932
|
+
// Sets the increment to shipped, records the deployed URL, runs the
|
|
1933
|
+
// Definition-of-Done gate (profile-aware), and clears claimed_by so the file
|
|
1934
|
+
// merges to main untouched by other actors.
|
|
1935
|
+
function cmdRelease(opts) {
|
|
1936
|
+
if (!hasIncrementLayout()) {
|
|
1937
|
+
return output(fail("NOT_MIGRATED", "No increments/ — run `state.js migrate` first."));
|
|
1938
|
+
}
|
|
1939
|
+
const id = opts.id || (readCursor().current_increment || "");
|
|
1940
|
+
if (!id) return output(fail("MISSING_ARG", "--id is required (or set the cursor)"));
|
|
1941
|
+
if (!isValidIncrementId(id)) return output(fail("INVALID_ID", `Invalid increment id '${id}' (must match inc-<slug>).`));
|
|
1942
|
+
const inc = readIncrement(id);
|
|
1943
|
+
if (!inc) return output(fail("UNKNOWN_INCREMENT", `No increment '${id}' under ${INCREMENTS_DIR}`));
|
|
1944
|
+
if (!opts.deployed_url) return output(fail("MISSING_ARG", "--deployed-url is required for release"));
|
|
1945
|
+
|
|
1946
|
+
// Definition-of-Done gate (profile-aware). strict: ANY open `- [ ]` line
|
|
1947
|
+
// blocks (no waivers). standard: open lines allowed only if each is WAIVED.
|
|
1948
|
+
// --force (OWNER override) bypasses either.
|
|
1949
|
+
const profile = resolveProfile(null, readTracking());
|
|
1950
|
+
const open = incrementOpenDoD(inc); // open AND unwaived
|
|
1951
|
+
const anyOpenLine = /(^|\n)\s*[-*]\s*\[\s\]/.test(inc.body || "");
|
|
1952
|
+
if (!opts.force) {
|
|
1953
|
+
if (profile === "strict" && anyOpenLine) {
|
|
1954
|
+
return output(fail("DOD_INCOMPLETE", `Definition of Done has unchecked items (strict profile, no waivers). Check or remove them before release, or use --force.`));
|
|
1955
|
+
}
|
|
1956
|
+
if (profile === "standard" && open.length) {
|
|
1957
|
+
return output(fail("DOD_INCOMPLETE", `Definition of Done has ${open.length} unchecked item(s) without a WAIVED: reason (standard profile). Waive with a reason or check them, or use --force.`));
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
inc.status = "shipped";
|
|
1962
|
+
inc.deployed_url = opts.deployed_url;
|
|
1963
|
+
inc.deploy_count = (inc.deploy_count || 0) + 1;
|
|
1964
|
+
const wasClaimedBy = inc.claimed_by;
|
|
1965
|
+
inc.claimed_by = ""; // release the claim
|
|
1966
|
+
writeIncrement(inc);
|
|
1967
|
+
regenerateViews(`Released ${id} → shipped`);
|
|
1968
|
+
recordLedgerEvent({ action: "release", phase_before: inc.num, phase_after: inc.num, status_before: wasClaimedBy ? "claimed" : inc.status, status_after: "shipped" });
|
|
1969
|
+
_trace("state-release", "allow", { id, deployed_url: inc.deployed_url });
|
|
1970
|
+
output({ ok: true, action: "release", id, status: "shipped", deployed_url: inc.deployed_url, claim_cleared_from: wasClaimedBy || "", next_command: "/qualia-report" });
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1256
1973
|
function cmdValidatePlan(opts) {
|
|
1257
1974
|
const phase = parseInt(opts.phase) || 1;
|
|
1258
1975
|
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
@@ -2014,11 +2731,20 @@ try {
|
|
|
2014
2731
|
case "next-report-id":
|
|
2015
2732
|
cmdNextReportId(opts);
|
|
2016
2733
|
break;
|
|
2734
|
+
case "migrate":
|
|
2735
|
+
cmdMigrate(opts);
|
|
2736
|
+
break;
|
|
2737
|
+
case "claim":
|
|
2738
|
+
cmdClaim(opts);
|
|
2739
|
+
break;
|
|
2740
|
+
case "release":
|
|
2741
|
+
cmdRelease(opts);
|
|
2742
|
+
break;
|
|
2017
2743
|
default:
|
|
2018
2744
|
output(
|
|
2019
2745
|
fail(
|
|
2020
2746
|
"UNKNOWN_COMMAND",
|
|
2021
|
-
`Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|backfill-milestones|next-report-id> [--options]`
|
|
2747
|
+
`Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|backfill-milestones|next-report-id|migrate|claim|release> [--options]`
|
|
2022
2748
|
)
|
|
2023
2749
|
);
|
|
2024
2750
|
}
|