qualia-framework 6.5.0 → 6.7.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/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
  }