@codyswann/lisa 4.22.7 → 4.22.9

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.
Files changed (143) hide show
  1. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  2. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  3. package/dist/core/upstream-evidence-manifest.js +22 -13
  4. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  5. package/dist/sync/lifecycle-defaults.d.ts +21 -0
  6. package/dist/sync/lifecycle-defaults.d.ts.map +1 -1
  7. package/dist/sync/lifecycle-defaults.js +21 -1
  8. package/dist/sync/lifecycle-defaults.js.map +1 -1
  9. package/dist/sync/registry.d.ts.map +1 -1
  10. package/dist/sync/registry.js +7 -1
  11. package/dist/sync/registry.js.map +1 -1
  12. package/package.json +3 -3
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +6 -5
  16. package/plugins/lisa/.codex-plugin/skills/lisa-github-prd-intake/SKILL.md +6 -5
  17. package/plugins/lisa/.codex-plugin/skills/lisa-github-sync/SKILL.md +2 -0
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-prd/SKILL.md +7 -3
  19. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +6 -5
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-sync/SKILL.md +3 -1
  21. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +6 -5
  22. package/plugins/lisa/.codex-plugin/skills/lisa-linear-evidence/SKILL.md +12 -11
  23. package/plugins/lisa/.codex-plugin/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  24. package/plugins/lisa/.codex-plugin/skills/lisa-linear-sync/SKILL.md +36 -20
  25. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  26. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-sync/SKILL.md +22 -2
  27. package/plugins/lisa/rules/reference/config-resolution.md +30 -3
  28. package/plugins/lisa/scripts/resolve-lifecycle-role.mjs +340 -0
  29. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +6 -5
  30. package/plugins/lisa/skills/lisa-github-prd-intake/SKILL.md +6 -5
  31. package/plugins/lisa/skills/lisa-github-sync/SKILL.md +2 -0
  32. package/plugins/lisa/skills/lisa-github-write-prd/SKILL.md +7 -3
  33. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +6 -5
  34. package/plugins/lisa/skills/lisa-jira-sync/SKILL.md +3 -1
  35. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +6 -5
  36. package/plugins/lisa/skills/lisa-linear-evidence/SKILL.md +12 -11
  37. package/plugins/lisa/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  38. package/plugins/lisa/skills/lisa-linear-sync/SKILL.md +36 -20
  39. package/plugins/lisa/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  40. package/plugins/lisa/skills/lisa-tracker-sync/SKILL.md +22 -2
  41. package/plugins/lisa-agy/plugin.json +1 -1
  42. package/plugins/lisa-agy/scripts/resolve-lifecycle-role.mjs +340 -0
  43. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +6 -5
  44. package/plugins/lisa-agy/skills/lisa-github-prd-intake/SKILL.md +6 -5
  45. package/plugins/lisa-agy/skills/lisa-github-sync/SKILL.md +2 -0
  46. package/plugins/lisa-agy/skills/lisa-github-write-prd/SKILL.md +7 -3
  47. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +6 -5
  48. package/plugins/lisa-agy/skills/lisa-jira-sync/SKILL.md +3 -1
  49. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +6 -5
  50. package/plugins/lisa-agy/skills/lisa-linear-evidence/SKILL.md +12 -11
  51. package/plugins/lisa-agy/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  52. package/plugins/lisa-agy/skills/lisa-linear-sync/SKILL.md +36 -20
  53. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  54. package/plugins/lisa-agy/skills/lisa-tracker-sync/SKILL.md +22 -2
  55. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  58. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-copilot/rules/reference/config-resolution.md +30 -3
  62. package/plugins/lisa-copilot/scripts/resolve-lifecycle-role.mjs +340 -0
  63. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +6 -5
  64. package/plugins/lisa-copilot/skills/lisa-github-prd-intake/SKILL.md +6 -5
  65. package/plugins/lisa-copilot/skills/lisa-github-sync/SKILL.md +2 -0
  66. package/plugins/lisa-copilot/skills/lisa-github-write-prd/SKILL.md +7 -3
  67. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +6 -5
  68. package/plugins/lisa-copilot/skills/lisa-jira-sync/SKILL.md +3 -1
  69. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +6 -5
  70. package/plugins/lisa-copilot/skills/lisa-linear-evidence/SKILL.md +12 -11
  71. package/plugins/lisa-copilot/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  72. package/plugins/lisa-copilot/skills/lisa-linear-sync/SKILL.md +36 -20
  73. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  74. package/plugins/lisa-copilot/skills/lisa-tracker-sync/SKILL.md +22 -2
  75. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +30 -3
  77. package/plugins/lisa-cursor/scripts/resolve-lifecycle-role.mjs +340 -0
  78. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +6 -5
  79. package/plugins/lisa-cursor/skills/lisa-github-prd-intake/SKILL.md +6 -5
  80. package/plugins/lisa-cursor/skills/lisa-github-sync/SKILL.md +2 -0
  81. package/plugins/lisa-cursor/skills/lisa-github-write-prd/SKILL.md +7 -3
  82. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +6 -5
  83. package/plugins/lisa-cursor/skills/lisa-jira-sync/SKILL.md +3 -1
  84. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +6 -5
  85. package/plugins/lisa-cursor/skills/lisa-linear-evidence/SKILL.md +12 -11
  86. package/plugins/lisa-cursor/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  87. package/plugins/lisa-cursor/skills/lisa-linear-sync/SKILL.md +36 -20
  88. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  89. package/plugins/lisa-cursor/skills/lisa-tracker-sync/SKILL.md +22 -2
  90. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-expo-agy/plugin.json +1 -1
  93. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  98. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  103. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  107. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  108. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  112. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  113. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  117. package/plugins/lisa-rails-agy/plugin.json +1 -1
  118. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  120. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  122. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  123. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  124. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  125. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  126. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  127. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  128. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  129. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  130. package/plugins/src/base/rules/reference/config-resolution.md +30 -3
  131. package/plugins/src/base/scripts/resolve-lifecycle-role.mjs +340 -0
  132. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +6 -5
  133. package/plugins/src/base/skills/lisa-github-prd-intake/SKILL.md +6 -5
  134. package/plugins/src/base/skills/lisa-github-sync/SKILL.md +2 -0
  135. package/plugins/src/base/skills/lisa-github-write-prd/SKILL.md +7 -3
  136. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +6 -5
  137. package/plugins/src/base/skills/lisa-jira-sync/SKILL.md +3 -1
  138. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +6 -5
  139. package/plugins/src/base/skills/lisa-linear-evidence/SKILL.md +12 -11
  140. package/plugins/src/base/skills/lisa-linear-prd-intake/SKILL.md +6 -5
  141. package/plugins/src/base/skills/lisa-linear-sync/SKILL.md +36 -20
  142. package/plugins/src/base/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +95 -44
  143. package/plugins/src/base/skills/lisa-tracker-sync/SKILL.md +22 -2
@@ -56,6 +56,11 @@ const ROLE_ARN =
56
56
  /^arn:(?:aws|aws-us-gov|aws-cn):iam::\d{12}:role\/[\w+=,.@/-]+$/;
57
57
  const PUBLICATION_COORDINATION_SCOPE = "AWS_BOOTSTRAP_";
58
58
  const PUBLICATION_LOCK_TTL_MS = 30 * 60 * 1000;
59
+ // Every provider and AWS child has its own 30-second deadline. Stop starting
60
+ // target mutations five minutes before another publisher may reap this lock,
61
+ // leaving ample room for the in-flight child to finish. This is a monotonic
62
+ // duration budget, not a wall clock used to order or expire provider records.
63
+ const PUBLICATION_CRITICAL_SECTION_BUDGET_MS = 25 * 60 * 1000;
59
64
 
60
65
  /**
61
66
  * Validate and normalize one complete bundle without changing its serialized
@@ -257,6 +262,22 @@ function writeProviderValue(cfg, id, value, write) {
257
262
  }
258
263
  }
259
264
 
265
+ function publicationBudgetGuard(startedAt, monotonicNow) {
266
+ return () => {
267
+ const elapsed = monotonicNow() - startedAt;
268
+ if (!Number.isFinite(elapsed) || elapsed < 0) {
269
+ throw new Error(
270
+ "publication lock monotonic timer became invalid; refusing further provider mutation"
271
+ );
272
+ }
273
+ if (elapsed >= PUBLICATION_CRITICAL_SECTION_BUDGET_MS) {
274
+ throw new Error(
275
+ "publication lock execution budget exceeded; refusing further provider mutation"
276
+ );
277
+ }
278
+ };
279
+ }
280
+
260
281
  /**
261
282
  * Enter the provider election for one AWS bootstrap record.
262
283
  *
@@ -427,23 +448,29 @@ export function releasePublicationLock(cfg, lock, operations = {}) {
427
448
  * snapshot can never overwrite a publication that finished while this process
428
449
  * was entering the election.
429
450
  */
430
- function withPublicationLock(cfg, operations, task) {
451
+ function withPublicationLock(cfg, operations, task, completedMessage) {
431
452
  const fetch = operations.fetch ?? fetchAll;
432
453
  const acquire = operations.acquireLock ?? acquirePublicationLock;
433
454
  const release = operations.releaseLock ?? releasePublicationLock;
455
+ const monotonicNow = operations.monotonicNow ?? (() => performance.now());
456
+ const startedAt = monotonicNow();
457
+ const assertWithinBudget = publicationBudgetGuard(startedAt, monotonicNow);
434
458
  const target = currentEntry(cfg, fetch);
435
459
  const lock = acquire(cfg, target, operations);
436
460
  let result;
437
461
  let taskError;
438
462
 
439
463
  try {
464
+ assertWithinBudget();
440
465
  const current = currentEntry(cfg, fetch);
441
466
  if (current.id !== target.id || current.projectId !== target.projectId) {
442
467
  throw new Error(
443
468
  `${BOOTSTRAP_KEY} moved after publication lock acquisition; retry from the new provider record`
444
469
  );
445
470
  }
446
- result = task(current);
471
+ assertWithinBudget();
472
+ result = task(current, assertWithinBudget);
473
+ assertWithinBudget();
447
474
  } catch (error) {
448
475
  taskError = error;
449
476
  }
@@ -455,15 +482,18 @@ function withPublicationLock(cfg, operations, task) {
455
482
  if (taskError) {
456
483
  throw new Error(`${firstLine(taskError)}; ${releaseReason}`);
457
484
  }
458
- throw new Error(releaseReason);
485
+ throw new Error(
486
+ `${completedMessage}; ${releaseReason}; remove the coordination record before the next publication`
487
+ );
459
488
  }
460
489
 
461
490
  if (taskError) throw taskError;
462
491
  return result;
463
492
  }
464
493
 
465
- function preflightUnderLock(cfg, entry, write) {
494
+ function preflightUnderLock(cfg, entry, write, assertWithinBudget) {
466
495
  try {
496
+ assertWithinBudget();
467
497
  writeProviderValue(cfg, entry.id, entry.value, write);
468
498
  } catch (error) {
469
499
  throw new Error(
@@ -482,8 +512,12 @@ function preflightUnderLock(cfg, entry, write) {
482
512
  */
483
513
  export function preflightAwsBootstrap(cfg, operations = {}) {
484
514
  const write = operations.write ?? writeSecret;
485
- return withPublicationLock(cfg, operations, entry =>
486
- preflightUnderLock(cfg, entry, write)
515
+ return withPublicationLock(
516
+ cfg,
517
+ operations,
518
+ (entry, assertWithinBudget) =>
519
+ preflightUnderLock(cfg, entry, write, assertWithinBudget),
520
+ "preflight completed"
487
521
  );
488
522
  }
489
523
 
@@ -496,12 +530,20 @@ export function preflightAwsBootstrap(cfg, operations = {}) {
496
530
  * @param {Function} write Provider writer.
497
531
  * @returns {string} Human-readable rollback outcome with no secret material.
498
532
  */
499
- function rollback(cfg, previous, candidateRaw, fetch, write) {
533
+ function rollback(
534
+ cfg,
535
+ previous,
536
+ candidateRaw,
537
+ fetch,
538
+ write,
539
+ assertWithinBudget
540
+ ) {
500
541
  try {
501
542
  const current = currentEntry(cfg, fetch);
502
543
  if (current.value !== candidateRaw) {
503
544
  return "rollback skipped because the provider changed after this publication";
504
545
  }
546
+ assertWithinBudget();
505
547
  writeProviderValue(cfg, previous.id, previous.value, write);
506
548
  const restored = currentEntry(cfg, fetch);
507
549
  if (restored.value !== previous.value) {
@@ -528,47 +570,56 @@ export function publishAwsBootstrap(raw, cfg, operations = {}) {
528
570
  const write = operations.write ?? writeSecret;
529
571
  const verify = operations.verify ?? verifyAwsBootstrap;
530
572
  const candidate = validateAwsBootstrap(raw);
531
- return withPublicationLock(cfg, operations, previous => {
532
- preflightUnderLock(cfg, previous, write);
533
-
534
- // Verify before the write. A candidate that cannot assume one of its roles
535
- // never becomes the value every new remote session receives.
536
- verify(candidate);
537
-
538
- if (candidate.raw === previous.value) {
539
- return {
540
- changed: false,
541
- profiles: candidate.profiles.map(profile => profile.name),
542
- };
543
- }
573
+ return withPublicationLock(
574
+ cfg,
575
+ operations,
576
+ (previous, assertWithinBudget) => {
577
+ preflightUnderLock(cfg, previous, write, assertWithinBudget);
578
+
579
+ // Verify before the write. A candidate that cannot assume one of its roles
580
+ // never becomes the value every new remote session receives.
581
+ verify(candidate);
582
+ assertWithinBudget();
583
+
584
+ if (candidate.raw === previous.value) {
585
+ return {
586
+ changed: false,
587
+ profiles: candidate.profiles.map(profile => profile.name),
588
+ };
589
+ }
544
590
 
545
- try {
546
- writeProviderValue(cfg, previous.id, candidate.raw, write);
547
- const stored = currentEntry(cfg, fetch);
548
- if (stored.value !== candidate.raw) {
591
+ try {
592
+ assertWithinBudget();
593
+ writeProviderValue(cfg, previous.id, candidate.raw, write);
594
+ const stored = currentEntry(cfg, fetch);
595
+ if (stored.value !== candidate.raw) {
596
+ throw new Error(
597
+ "provider read-back did not match the candidate exactly"
598
+ );
599
+ }
600
+ const storedCandidate = validateAwsBootstrap(stored.value);
601
+ verify(storedCandidate);
602
+ assertWithinBudget();
603
+ return {
604
+ changed: true,
605
+ profiles: storedCandidate.profiles.map(profile => profile.name),
606
+ };
607
+ } catch (error) {
608
+ const rollbackOutcome = rollback(
609
+ cfg,
610
+ previous,
611
+ candidate.raw,
612
+ fetch,
613
+ write,
614
+ assertWithinBudget
615
+ );
549
616
  throw new Error(
550
- "provider read-back did not match the candidate exactly"
617
+ `${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
551
618
  );
552
619
  }
553
- const storedCandidate = validateAwsBootstrap(stored.value);
554
- verify(storedCandidate);
555
- return {
556
- changed: true,
557
- profiles: storedCandidate.profiles.map(profile => profile.name),
558
- };
559
- } catch (error) {
560
- const rollbackOutcome = rollback(
561
- cfg,
562
- previous,
563
- candidate.raw,
564
- fetch,
565
- write
566
- );
567
- throw new Error(
568
- `${BOOTSTRAP_KEY} publication failed: ${firstLine(error)}; ${rollbackOutcome}`
569
- );
570
- }
571
- });
620
+ },
621
+ "publication completed"
622
+ );
572
623
  }
573
624
 
574
625
  /** Read a candidate from stdin without ever placing it in argv. */
@@ -21,7 +21,27 @@ See the `config-resolution` rule for configuration and dispatch table.
21
21
  - Anything else → stop and report `"Unknown tracker '<value>' in .lisa.config.json. Expected 'jira', 'github', or 'linear'."`
22
22
  3. Pass through the output.
23
23
 
24
- `$ARGUMENTS` is forwarded verbatim, including the optional `--rollup` flag (see "Parent status rollup" below), the vendor lane-write flag (`--update-label` on GitHub, `--update-state` on Linear), `pr_url=<url>`, and `merge_sha=<sha>`. The shim never interprets these — the vendor skill does.
24
+ `$ARGUMENTS` is forwarded verbatim, including the optional `--rollup` flag (see "Parent status rollup" below), `pr_url=<url>`, and `merge_sha=<sha>`. The shim never interprets these — the vendor skill does.
25
+
26
+ > **There is no per-milestone lane-write flag on any vendor.** This paragraph previously documented `--update-label` on GitHub and `--update-state` on Linear. `--update-label` was never implemented — `lisa-github-sync` says flatly *"This skill never relabels"* — so a caller passing it got a silent no-op on GitHub and a real write on Linear for the equivalent flag. Both are gone. `--rollup` is the only write path a sync skill has.
27
+
28
+ ## Tracker status vocabulary — binding on all three arms
29
+
30
+ Every suggested or performed transition names **only a role the project configured**, never a status, state or label discovered from the tracker's live workflow (transition lists, board columns, `type`-derived matches, other tickets). This binds a lead performing tracker writes exactly as it binds a subagent.
31
+
32
+ Two consequences the vendor arms must not restate differently:
33
+
34
+ - **A milestone whose role is unset gets a comment, not a transition.** `review` and the `qa.*` roles are optional and carry **no default** — omitting one means the project does not run that step, and the lifecycle skips it. "Unset" must never resolve to a built-in name; that is the difference between "not customized" and "we deliberately don't do this".
35
+ - **A fallback may inform a read; it may never supply a write target.** Where a vendor exposes one (Linear resolves states by `type`), it selects by board position rather than intent — so on a board carrying more than one plausible state it returns whichever sits earliest, which is exactly the human-only lane a project left out of its config on purpose.
36
+
37
+ Resolve roles through the shared resolver rather than an inlined helper:
38
+
39
+ ```bash
40
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
41
+ --role <role> --vendor <jira|linear|github> --intent <read|write> [--env <env>]
42
+ ```
43
+
44
+ Exit `0` with a value means configured; exit `0` with **empty** output means an optional role is unset — skip the transition; exit `2` means a required role is unset or a write was refused a fallback value.
25
45
 
26
46
  If `$ARGUMENTS` is empty, all vendor skills auto-detect a ticket reference from the active plan file (most recently modified `.md` in `plans/`).
27
47
 
@@ -65,6 +85,6 @@ This is the reverse half of `lisa-git-submit-pr`'s PR body linkage. A PR that me
65
85
  ## Rules
66
86
 
67
87
  - Idempotent updates — running sync at the same milestone twice should not produce duplicate comments. Vendor skills enforce this.
68
- - Never auto-transition a lane the caller did not ask for. On GitHub the canonical signal is the `status:*` label, written only with `--update-label`; on Linear it is the native workflow state, written only with `--update-state`. Without the flag, every vendor arm only suggests.
88
+ - Never transition a leaf's lane from a sync milestone on any vendor. The canonical signal differs — the `status:*` label on GitHub, the workflow status on JIRA, the native workflow state on Linear — but the owner is the same everywhere: build-intake / the vendor agent. Every arm only suggests. `--rollup` (parent derivation) is the sole exception, and it derives the parent from its children rather than advancing a leaf.
69
89
  - Parent rollup derives state from children per the `leaf-only-lifecycle` rule; it never sets a parent to `ready` and never resolves a dev/staging `done` in this single-environment repo.
70
90
  - Pull request backlinks are mandatory when `pr_url=<url>` is present: native first, managed-comment fallback, never silently dropped.
@@ -352,7 +352,7 @@ When `github.projects.v2` is present, later setup/doctor and writer preflight va
352
352
  |-------|---------------|-------|
353
353
  | `linear.workspace` | `tracker = "linear"`, `source = "linear"`, or any `linear-*` skill is invoked | Linear workspace slug (e.g. `acme`). |
354
354
  | `linear.teamKey` | `tracker = "linear"` | Linear team key (e.g. `ENG`). The team owns the destination Issues. For source mode, projects are workspace-scoped or team-scoped per the URL passed. |
355
- | `linear.workflow` | `tracker = "linear"` | Workflow **state** name per build lifecycle role — the Linear analogue of `jira.workflow`, not of `github.labels`. Defaults `{ ready: "Ready", claimed: "In Progress", review: "In Review", blocked: "Blocked", done: { dev: "On Dev", staging: "On Stg", production: "Done" } }`. **`ready` is a dedicated state, never the team's default** — see below. Resolve and verify with `/lisa:setup:linear`. |
355
+ | `linear.workflow` | `tracker = "linear"` | Workflow **state** name per build lifecycle role — the Linear analogue of `jira.workflow`, not of `github.labels`. Defaults `{ ready: "Ready", claimed: "In Progress", blocked: "Blocked", done: { dev: "On Dev", staging: "On Stg", production: "Done" } }` — **no `review` key**, because it is optional and a default would be materialized into the project's config by `lisa sync`. **`ready` is a dedicated state, never the team's default** — see below. Resolve and verify with `/lisa:setup:linear`. |
356
356
  | `linear.labels` | `tracker = "linear"` or `source = "linear"` | **Markers and the PRD lane only.** `build.human_needed` (default `human-needed`) and the `prd.*` map. The build lifecycle does **not** live here — see `linear.workflow`. |
357
357
 
358
358
  ##### Why Linear uses states, not labels
@@ -439,13 +439,40 @@ Every lifecycle skill operates on a fixed set of **roles** (`ready`, `claimed`,
439
439
  |---|---|---|---|---|
440
440
  | `ready` | Human signal "this is buildable; agent may claim" | `Ready` (status) | `Ready` (state) | `status:ready` (label) |
441
441
  | `claimed` | Agent has picked the item up | `In Progress` (status) | `In Progress` (state) | `status:in-progress` (label) |
442
- | `review` | Optional post-build review hold, when a tracker/project still uses one | `Code Review` (status) | `In Review` (state) | no default review label |
442
+ | `review` | Optional post-build review hold, when a tracker/project still uses one | **no default** | **no default** | **no default** |
443
443
  | `blocked` | Agent stopped on triage ambiguities or external blocker | `Blocked` (status) | `Blocked` (state) | `status:blocked` (label) |
444
444
  | `done` | Terminal state for this work, **env-keyed** | map of env → status | map of env → state | map of env → label |
445
445
 
446
446
  **JIRA and Linear resolve roles to native workflow statuses/states** (`jira.workflow`, `linear.workflow`); **GitHub resolves them to labels** (`github.labels.build`) because GitHub Issues has no workflow-state field at all. A role transition is therefore a *state move* on JIRA and Linear, and a *label swap* on GitHub. Skills operate on roles and never hardcode either form.
447
447
 
448
- `review` is optional. GitHub build intake skips it by default and moves successful builds directly from `claimed` to the configured `done` label. Linear and JIRA projects that still use a post-build review hold can configure `review`; projects that keep the ticket in `claimed` until terminal can omit it and lifecycle skills will skip the intermediate transition.
448
+ ### R1 — an absent OPTIONAL role means SKIP, never a default
449
+
450
+ `ready`, `claimed`, `blocked` and `done` are **required**; omitting one is a setup defect and a skill must say so. `review`, the `qa.*` roles and `human_needed` are **optional and carry no built-in default on any vendor**. Omitting one means the project does not run that step, and every lifecycle skill skips the transition — the item simply stays in its current role.
451
+
452
+ A default on an optional role breaks this twice over. Vendor default maps are `defaultValue`s in `sync/registry`, so `lisa sync` **materializes** them into a project's `.lisa.config.json` — a project that deliberately omitted `review` finds it written back in. And as a resolution fallback, a default makes "unset" indistinguishable from "not customized", so no project can express *"we have no agent review step."*
453
+
454
+ Measured downstream: a project bound no `review` role — its policy was that a PR-open ticket stays in `claimed` until it reaches an environment — and agents moved two issues into a human-only review state regardless, because every layer that could have honoured the omission supplied a default instead.
455
+
456
+ GitHub has always been correct here (`BUILD_LABEL_DEFAULTS` seeds no `review`), and `lisa-jira-evidence/scripts/post-evidence.sh` has always implemented it correctly (`REVIEW=""`, explicit skip branch). The default maps for JIRA and Linear now agree with them.
457
+
458
+ ### R2 — a fallback may inform a READ, never supply a WRITE target
459
+
460
+ Where a vendor exposes a fallback for a missing role — Linear resolves states by `type`, since Linear states carry one — it may be used to *read*. It must **never** supply a value to write.
461
+
462
+ Such a fallback selects by **board position**, not intent. On a board carrying more than one plausible state it returns whichever sits earliest, and the states that surface that way are precisely the human-only lanes a project kept out of its config on purpose. Measured on one board: with `blocked` and `claimed` already bound, the lowest-position unbound `started` state was a human-only review lane, and that is exactly where two issues landed.
463
+
464
+ `ready` has no fallback at all, in either direction — see below.
465
+
466
+ ### The single resolver
467
+
468
+ Every skill resolves roles through `${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs`, never an inlined `read_role()` helper. Twelve skills previously inlined one and produced **eleven distinct implementations**; that copy-paste is what let the vendors drift apart on R1.
469
+
470
+ ```bash
471
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
472
+ --role <role> --vendor <jira|linear|github> --intent <read|write> [--env <env>]
473
+ ```
474
+
475
+ Exit `0` with a value → configured. Exit `0` with **empty** output → an optional role is unset; skip the transition. Exit `2` → a required role is unset, or a write was refused a fallback value.
449
476
 
450
477
  **`ready` must never resolve to the team's DEFAULT state.** `Todo` is where Linear puts a brand-new issue, so using it for `ready` inverts the gate: the lane stops meaning "a human flipped this to build-ready" and starts meaning "nobody has touched this". Measured on the first team migrated: 20 issues in the lane, only 8 ever explicitly marked ready. JIRA avoids this because `jira.workflow.ready` is a dedicated `Ready` status while a fresh ticket lands in the project default.
451
478
 
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lifecycle-role resolution — the single resolver for every vendor.
4
+ *
5
+ * ## The defect this replaces
6
+ *
7
+ * Twelve skills inlined their own `read_role()` bash helper. Hashing the
8
+ * function bodies produced ELEVEN distinct implementations — only
9
+ * `lisa-linear-build-intake` and `lisa-linear-evidence` agreed. Copy-paste with
10
+ * no shared source is why the three vendors drifted apart on the same contract.
11
+ *
12
+ * The drift was not cosmetic. `lisa-linear-evidence` resolved
13
+ * `read_role review "In Review"` — a NON-EMPTY default — while
14
+ * `lisa-jira-evidence/scripts/post-evidence.sh` resolved the same role with
15
+ * `REVIEW=""` and an explicit skip branch. Same role, opposite behaviour, on the
16
+ * question of whether an unconfigured review step exists at all.
17
+ *
18
+ * ## R1 — absent means SKIP, never default
19
+ *
20
+ * `ready`, `claimed`, `blocked` and `done` are REQUIRED: a project that omits
21
+ * them is misconfigured and the caller should say so. `review` and the `qa.*`
22
+ * roles are OPTIONAL and have NO built-in default, so omitting one means the
23
+ * lifecycle skips that transition entirely.
24
+ *
25
+ * This is the difference between "not customized" and "we don't do this step",
26
+ * which a defaulting resolver cannot express. Measured downstream: a project
27
+ * deliberately bound no `review` role — a PR-open ticket was meant to stay in
28
+ * `claimed` until it reached an environment — and agents moved issues into a
29
+ * human-only review state anyway, because every layer that could have honoured
30
+ * the omission supplied a default instead.
31
+ *
32
+ * ## R2 — a fallback may inform a READ, never supply a WRITE target
33
+ *
34
+ * Linear states carry a machine-readable `type`, so a skill may fall back to
35
+ * resolving a role by type when the configured name is missing — the
36
+ * `config-resolution` rule permits this "but only to *read* … it must never
37
+ * invent a state to write into."
38
+ *
39
+ * Nothing enforced that sentence. The fallback selects the lowest-position
40
+ * `started` state, and on the board where this was measured the states ranked
41
+ * `blocked` (-1989.26), `claimed` (-1478.50), then two unbound human-only review
42
+ * states at -1209.69 and -1079.70. With `blocked` and `claimed` already taken,
43
+ * the fallback returned the first unbound one — precisely where two issues
44
+ * landed. Not fuzzy matching; position ordering, used for a write.
45
+ *
46
+ * `--intent=write` therefore refuses to return a fallback value and reports a
47
+ * setup defect instead. `--intent=read` may use one, and always reports which.
48
+ *
49
+ * ## Why a script rather than a shared bash snippet
50
+ *
51
+ * A snippet is copy-paste, which is the disease. Skills shell out to
52
+ * `${CLAUDE_PLUGIN_ROOT}/scripts/…` for exactly this reason elsewhere
53
+ * (`lifecycle-label-trust.mjs`, `automation-run-record.mjs`), and a real process
54
+ * boundary means the resolution can be unit-tested rather than re-read.
55
+ * @module scripts/resolve-lifecycle-role
56
+ */
57
+
58
+ import { readFileSync, realpathSync } from "node:fs";
59
+ import { fileURLToPath } from "node:url";
60
+
61
+ /** Config file consulted first; its keys override the committed file. */
62
+ export const LOCAL_CONFIG = ".lisa.config.local.json";
63
+
64
+ /** The committed config file. */
65
+ export const GLOBAL_CONFIG = ".lisa.config.json";
66
+
67
+ /**
68
+ * Roles a project MUST bind. Omitting one is a setup defect, not a policy
69
+ * choice — there is no coherent lifecycle without a ready lane, a claimed lane,
70
+ * a blocked lane and a terminal.
71
+ */
72
+ export const REQUIRED_ROLES = Object.freeze([
73
+ "ready",
74
+ "claimed",
75
+ "blocked",
76
+ "done",
77
+ ]);
78
+
79
+ /**
80
+ * Roles a project MAY bind. Omitting one means the lifecycle skips that
81
+ * transition. These have NO built-in default — that is the whole point of R1.
82
+ */
83
+ export const OPTIONAL_ROLES = Object.freeze([
84
+ "review",
85
+ "qa.queue",
86
+ "qa.certified",
87
+ "human_needed",
88
+ ]);
89
+
90
+ /** Where each vendor keeps its build lifecycle role map. */
91
+ export const VENDOR_ROOTS = Object.freeze({
92
+ jira: "jira.workflow",
93
+ linear: "linear.workflow",
94
+ github: "github.labels.build",
95
+ });
96
+
97
+ /** Resolution outcomes, reported so a caller never has to guess what happened. */
98
+ export const OUTCOMES = Object.freeze({
99
+ CONFIGURED: "configured",
100
+ UNSET_OPTIONAL: "unset-optional",
101
+ UNSET_REQUIRED: "unset-required",
102
+ FALLBACK_REFUSED: "fallback-refused-for-write",
103
+ });
104
+
105
+ /**
106
+ * Read a dotted path out of a parsed config object.
107
+ *
108
+ * @param {unknown} source parsed config
109
+ * @param {string} path dotted key path, e.g. `linear.workflow.review`
110
+ * @returns {unknown} the value, or undefined when any segment is missing
111
+ */
112
+ export const readPath = (source, path) => {
113
+ let cursor = source;
114
+ for (const segment of path.split(".")) {
115
+ if (cursor === null || typeof cursor !== "object" || !(segment in cursor))
116
+ return undefined;
117
+ cursor = cursor[segment];
118
+ }
119
+ return cursor;
120
+ };
121
+
122
+ /**
123
+ * Parse a config file, treating an unreadable or malformed file as absent.
124
+ *
125
+ * A malformed local override must not silently mask the committed file, so the
126
+ * caller is told which files actually parsed via the returned `sources`.
127
+ *
128
+ * @param {string} file path to read
129
+ * @returns {{ value: object | undefined, error: string | undefined }} parse result
130
+ */
131
+ export const parseConfig = file => {
132
+ let raw;
133
+ try {
134
+ raw = readFileSync(file, "utf8");
135
+ } catch {
136
+ return { value: undefined, error: undefined };
137
+ }
138
+ try {
139
+ return { value: JSON.parse(raw), error: undefined };
140
+ } catch (cause) {
141
+ return {
142
+ value: undefined,
143
+ error: `${file} is not valid JSON: ${cause.message}`,
144
+ };
145
+ }
146
+ };
147
+
148
+ /**
149
+ * Resolve one lifecycle role for one vendor.
150
+ *
151
+ * Resolution order is local config → global config → (nothing). There is no
152
+ * built-in default tier: see R1 in the module docblock.
153
+ *
154
+ * @param {object} options resolution inputs
155
+ * @param {string} options.role role name (`ready`, `review`, `qa.queue`, …)
156
+ * @param {string} options.vendor one of `jira` | `linear` | `github`
157
+ * @param {"read" | "write"} [options.intent] what the caller will do with it
158
+ * @param {string} [options.env] env key when resolving the env-keyed `done` map
159
+ * @param {object} [options.local] pre-parsed local config (tests)
160
+ * @param {object} [options.global] pre-parsed global config (tests)
161
+ * @param {string} [options.fallback] a value a vendor fallback would supply
162
+ * @returns {{ value: string, outcome: string, source: string, message: string }} resolution
163
+ */
164
+ export const resolveRole = ({
165
+ role,
166
+ vendor,
167
+ intent = "read",
168
+ env,
169
+ local,
170
+ global: globalConfig,
171
+ fallback,
172
+ }) => {
173
+ const root = VENDOR_ROOTS[vendor];
174
+ if (!root) {
175
+ return {
176
+ value: "",
177
+ outcome: OUTCOMES.UNSET_REQUIRED,
178
+ source: "none",
179
+ message: `Unknown vendor '${vendor}'. Expected one of: ${Object.keys(VENDOR_ROOTS).join(", ")}.`,
180
+ };
181
+ }
182
+
183
+ // `done` is env-keyed on every vendor; an env selects one rung of the map.
184
+ const path =
185
+ env && role === "done" ? `${root}.done.${env}` : `${root}.${role}`;
186
+
187
+ for (const [label, source] of [
188
+ ["local", local],
189
+ ["global", globalConfig],
190
+ ]) {
191
+ const found = readPath(source, path);
192
+ if (typeof found === "string" && found.length > 0) {
193
+ return {
194
+ value: found,
195
+ outcome: OUTCOMES.CONFIGURED,
196
+ source: label,
197
+ message: `${path} = ${found} (from ${label} config)`,
198
+ };
199
+ }
200
+ }
201
+
202
+ const optional = OPTIONAL_ROLES.includes(role);
203
+
204
+ // R2 — a fallback may inform a read, never supply a write target.
205
+ if (fallback && intent === "write") {
206
+ return {
207
+ value: "",
208
+ outcome: OUTCOMES.FALLBACK_REFUSED,
209
+ source: "none",
210
+ message:
211
+ `Refusing to write to '${fallback}': it was derived by fallback, not named at ${path}. ` +
212
+ `A fallback may inform a read but must never supply a write target (config-resolution). ` +
213
+ `Bind ${path} explicitly, or run /lisa:setup:${vendor}.`,
214
+ };
215
+ }
216
+ if (fallback) {
217
+ return {
218
+ value: fallback,
219
+ outcome: OUTCOMES.CONFIGURED,
220
+ source: "fallback",
221
+ message: `${path} is unset; using fallback '${fallback}' for a READ only.`,
222
+ };
223
+ }
224
+
225
+ if (optional) {
226
+ return {
227
+ value: "",
228
+ outcome: OUTCOMES.UNSET_OPTIONAL,
229
+ source: "none",
230
+ message:
231
+ `No ${path} configured; skipping the ${role} transition. ` +
232
+ `This is a supported configuration, not an error — the item stays in its current role.`,
233
+ };
234
+ }
235
+
236
+ return {
237
+ value: "",
238
+ outcome: OUTCOMES.UNSET_REQUIRED,
239
+ source: "none",
240
+ message: `Required role '${role}' is not configured at ${path}. Run /lisa:setup:${vendor}.`,
241
+ };
242
+ };
243
+
244
+ /**
245
+ * Parse `--key=value` / `--key value` argv into a plain object.
246
+ *
247
+ * @param {string[]} argv raw arguments
248
+ * @returns {Record<string, string>} parsed flags
249
+ */
250
+ export const parseArgs = argv => {
251
+ const out = {};
252
+ for (let index = 0; index < argv.length; index += 1) {
253
+ const token = argv[index];
254
+ if (!token.startsWith("--")) continue;
255
+ const [key, inline] = token.slice(2).split("=");
256
+ if (inline !== undefined) {
257
+ out[key] = inline;
258
+ continue;
259
+ }
260
+ const next = argv[index + 1];
261
+ if (next && !next.startsWith("--")) {
262
+ out[key] = next;
263
+ index += 1;
264
+ } else {
265
+ out[key] = "true";
266
+ }
267
+ }
268
+ return out;
269
+ };
270
+
271
+ /**
272
+ * CLI entry point.
273
+ *
274
+ * Exit codes are the contract callers branch on:
275
+ * - `0` with a value on stdout — the role is configured (or a read fallback).
276
+ * - `0` with EMPTY stdout — an optional role is unset; skip the transition.
277
+ * - `2` — a required role is unset, or a write was refused a fallback value.
278
+ *
279
+ * The distinction between the two `0` cases is deliberate: an unset optional
280
+ * role is a supported configuration, so it must not look like a failure.
281
+ *
282
+ * @param {string[]} argv arguments after the script name
283
+ * @returns {number} process exit code
284
+ */
285
+ export const main = argv => {
286
+ const args = parseArgs(argv);
287
+ if (!args.role || !args.vendor) {
288
+ process.stderr.write(
289
+ "usage: resolve-lifecycle-role.mjs --role <name> --vendor <jira|linear|github> " +
290
+ "[--intent read|write] [--env dev|staging|production] [--fallback <name>]\n"
291
+ );
292
+ return 2;
293
+ }
294
+
295
+ const local = parseConfig(args.local ?? LOCAL_CONFIG);
296
+ const globalConfig = parseConfig(args.config ?? GLOBAL_CONFIG);
297
+ for (const parsed of [local, globalConfig]) {
298
+ if (parsed.error) {
299
+ process.stderr.write(`${parsed.error}\n`);
300
+ return 2;
301
+ }
302
+ }
303
+
304
+ const result = resolveRole({
305
+ role: args.role,
306
+ vendor: args.vendor,
307
+ intent: args.intent ?? "read",
308
+ env: args.env,
309
+ fallback: args.fallback,
310
+ local: local.value,
311
+ global: globalConfig.value,
312
+ });
313
+
314
+ if (args.json === "true") {
315
+ process.stdout.write(`${JSON.stringify(result)}\n`);
316
+ } else if (result.value) {
317
+ process.stdout.write(`${result.value}\n`);
318
+ }
319
+
320
+ if (result.outcome === OUTCOMES.CONFIGURED) return 0;
321
+ if (result.outcome === OUTCOMES.UNSET_OPTIONAL) {
322
+ process.stderr.write(`${result.message}\n`);
323
+ return 0;
324
+ }
325
+ process.stderr.write(`${result.message}\n`);
326
+ return 2;
327
+ };
328
+
329
+ const invokedDirectly = (() => {
330
+ try {
331
+ return (
332
+ realpathSync(process.argv[1] ?? "") ===
333
+ realpathSync(fileURLToPath(import.meta.url))
334
+ );
335
+ } catch {
336
+ return false;
337
+ }
338
+ })();
339
+
340
+ if (invokedDirectly) process.exit(main(process.argv.slice(2)));
@@ -38,11 +38,12 @@ Build-queue label names are read from `.lisa.config.json` `github.labels.build.*
38
38
  ```bash
39
39
  # Read role with default fallback. Local overrides global per-key.
40
40
  read_role() {
41
- local role="$1" default="$2"
42
- local local_v global_v
43
- local_v=$(jq -r ".github.labels.build.${role} // empty" .lisa.config.local.json 2>/dev/null)
44
- global_v=$(jq -r ".github.labels.build.${role} // empty" .lisa.config.json 2>/dev/null)
45
- echo "${local_v:-${global_v:-$default}}"
41
+ # Single resolver — see config-resolution "The single resolver".
42
+ # Exit 0 + empty output means an OPTIONAL role is unset: skip that transition,
43
+ # never substitute the second argument. A default on an optional role is what
44
+ # made "unset" indistinguishable from "not customized".
45
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/resolve-lifecycle-role.mjs" \
46
+ --role "$1" --vendor github --intent "${3:-read}" 2>/dev/null
46
47
  }
47
48
 
48
49
  READY=$(read_role ready "status:ready")