guardian-framework 0.1.105 → 0.1.109

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 (69) hide show
  1. package/README.md +11 -2
  2. package/dist/cli.js +232 -16
  3. package/dist/exports.js +232 -16
  4. package/package.json +1 -1
  5. package/templates/languages/rust-patterns.md +5 -2
  6. package/templates/pi/context/checklists.md +15 -1
  7. package/templates/pi/domain/exploration.md +9 -0
  8. package/templates/pi/domain/ubiquitous-language.md +12 -0
  9. package/templates/pi/extensions/architect-lib/generators.ts +158 -7
  10. package/templates/pi/extensions/architect-lib/helpers.ts +49 -0
  11. package/templates/pi/extensions/architect-lib/types.ts +44 -2
  12. package/templates/pi/extensions/architect.ts +102 -42
  13. package/templates/pi/extensions/ask-user-question.ts +2 -2
  14. package/templates/pi/extensions/bash-guard.ts +2 -2
  15. package/templates/pi/extensions/config-reload.ts +12 -5
  16. package/templates/pi/extensions/coordinator.ts +2 -2
  17. package/templates/pi/extensions/curator.ts +1 -1
  18. package/templates/pi/extensions/domain-explorer.ts +50 -3
  19. package/templates/pi/extensions/filechanges.ts +2 -2
  20. package/templates/pi/extensions/goal-loop.ts +3 -3
  21. package/templates/pi/extensions/hooks.ts +1 -1
  22. package/templates/pi/extensions/pi-env.d.ts +96 -0
  23. package/templates/pi/extensions/pipeline.ts +1 -1
  24. package/templates/pi/extensions/project-scaffolder.ts +1 -1
  25. package/templates/pi/extensions/read-only-mode.ts +5 -1
  26. package/templates/pi/extensions/tsconfig.json +9 -2
  27. package/templates/pi/preflight_report.json +1 -1
  28. package/templates/pi/prompts/ci-blueprint.md +2 -2
  29. package/templates/pi/scripts/check_model_judgment.sh +28 -0
  30. package/templates/pi/scripts/ci/check_judgment_contracts.sh +93 -0
  31. package/templates/pi/scripts/ci/run_preflight.sh +3 -3
  32. package/templates/pi/scripts/ci/run_stage.sh +1 -1
  33. package/templates/pi/scripts/ci/stage_lint.sh +1 -1
  34. package/templates/pi/scripts/coverage.sh +58 -0
  35. package/templates/pi/scripts/install_coverage_tools.sh +43 -0
  36. package/templates/pi/scripts/languages/go/check_model_judgment.sh +70 -0
  37. package/templates/pi/scripts/languages/go/check_runtime_reachability.sh +34 -0
  38. package/templates/pi/scripts/languages/go/coverage.sh +78 -0
  39. package/templates/pi/scripts/languages/go/install_coverage_tools.sh +17 -0
  40. package/templates/pi/scripts/languages/java/check_model_judgment.sh +142 -0
  41. package/templates/pi/scripts/languages/java/check_runtime_reachability.sh +31 -0
  42. package/templates/pi/scripts/languages/java/coverage.sh +103 -0
  43. package/templates/pi/scripts/languages/java/install_coverage_tools.sh +40 -0
  44. package/templates/pi/scripts/languages/java/validate-architecture.sh +20 -0
  45. package/templates/pi/scripts/languages/python/check_model_judgment.sh +67 -0
  46. package/templates/pi/scripts/languages/python/check_runtime_reachability.sh +34 -0
  47. package/templates/pi/scripts/languages/python/coverage.sh +88 -0
  48. package/templates/pi/scripts/languages/python/install_coverage_tools.sh +22 -0
  49. package/templates/pi/scripts/languages/rust/check_model_judgment.sh +61 -0
  50. package/templates/pi/scripts/languages/rust/check_runtime_reachability.sh +40 -0
  51. package/templates/pi/scripts/languages/rust/coverage.sh +79 -0
  52. package/templates/pi/scripts/languages/rust/install_coverage_tools.sh +20 -0
  53. package/templates/pi/scripts/languages/rust/validate-tests.sh +9 -6
  54. package/templates/pi/scripts/languages/typescript/check_model_judgment.sh +81 -0
  55. package/templates/pi/scripts/languages/typescript/check_runtime_reachability.sh +41 -0
  56. package/templates/pi/scripts/languages/typescript/coverage.sh +154 -0
  57. package/templates/pi/scripts/languages/typescript/install_coverage_tools.sh +41 -0
  58. package/templates/pi/scripts/languages/typescript/validate-canonical.sh +6 -2
  59. package/templates/pi/scripts/languages/typescript/validate-ci.sh +2 -2
  60. package/templates/pi/scripts/validate-architecture-readiness.sh +29 -0
  61. package/templates/pi/scripts/validate-exploration-depth.sh +112 -0
  62. package/templates/pi/scripts/validate-runtime-wiring.sh +35 -0
  63. package/templates/pi/skills/agents/architecture-validator.md +28 -0
  64. package/templates/pi/skills/agents/test-validator.md +1 -1
  65. package/templates/pi/skills/go-enterprise-codegen.md +46 -0
  66. package/templates/pi/skills/java-spring-enterprise-codegen.md +50 -0
  67. package/templates/pi/skills/python-enterprise-codegen.md +46 -0
  68. package/templates/pi/skills/rust-enterprise-codegen.md +46 -0
  69. package/templates/pi/skills/typescript-enterprise-codegen.md +46 -0
@@ -197,6 +197,26 @@ export function generateIssueMarkdown(
197
197
  const title = buildIssueTitle(comp, moduleId);
198
198
  const inScope = buildInScope(comp, compAcs, implSteps, moduleId);
199
199
 
200
+ // ADR-008: append compact modeling directives for aggregate/root components.
201
+ const isTactical = comp.modelingDepth === "tactical" || comp.aggregateRoot;
202
+ if (isTactical && (comp.invariants?.length || comp.events)) {
203
+ if (comp.invariants?.length) {
204
+ inScope.push("Honor the frozen aggregate invariants: " + comp.invariants.join("; "));
205
+ }
206
+ if (comp.events) {
207
+ const ev = [
208
+ ...(comp.events.publishes?.length ? ["publishes " + comp.events.publishes.join(", ")] : []),
209
+ ...(comp.events.subscribes?.length ? ["subscribes " + comp.events.subscribes.join(", ")] : []),
210
+ ].join(", ");
211
+ if (ev) inScope.push("Domain events: " + ev);
212
+ }
213
+ if (comp.aggregateRoot) {
214
+ inScope.push(
215
+ "Implement " + comp.name + " as the aggregate root — no setters; state changes via behavior methods; repository interface owned by the domain layer",
216
+ );
217
+ }
218
+ }
219
+
200
220
  // Language-aware test paths
201
221
  const lang = detectLanguage(cwd);
202
222
  const ext = implExtension(lang);
@@ -373,6 +393,30 @@ export function generateContractFreezeMarkdown(
373
393
  const implSteps = parseImplSequence(doc);
374
394
  const allAcs = parseAcTable(doc);
375
395
 
396
+ // ADR-008 modeling contract for components carrying depth metadata.
397
+ const modelingParts = slice.nextLogicalSlice
398
+ .filter((c: ModuleComponent) => c.modelingDepth === "tactical" || c.aggregateRoot || c.invariants?.length || c.events)
399
+ .map((c: ModuleComponent) => {
400
+ const lines: string[] = [`### ${c.name}`];
401
+ if (c.aggregateRoot) {
402
+ lines.push("- Aggregate root — all state changes flow through behavior methods (no setters)");
403
+ lines.push("- Repository interface is owned by the domain layer (interface-ownership, ADR-008)");
404
+ lines.push("- One aggregate per transaction; reference other aggregates by identity");
405
+ }
406
+ if (c.invariants?.length) {
407
+ lines.push("- Frozen invariants (become pre-generated failing tests):");
408
+ for (const inv of c.invariants) lines.push(` - ${inv}`);
409
+ }
410
+ if (c.events) {
411
+ if (c.events.publishes?.length) lines.push("- Publishes: " + c.events.publishes.join(", "));
412
+ if (c.events.subscribes?.length) lines.push("- Subscribes: " + c.events.subscribes.join(", "));
413
+ }
414
+ return lines.join("\n");
415
+ });
416
+ const modelingContract = modelingParts.length > 0
417
+ ? "## Modeling Contract (ADR-008)\n\n" + modelingParts.join("\n\n")
418
+ : "";
419
+
376
420
  return `---
377
421
  guardian_issue:
378
422
  id: "ISSUE-CONTRACT-FREEZE"
@@ -481,6 +525,8 @@ ${implSteps.length > 0
481
525
  ? `## Full Implementation Sequence (for reference)\n\n${implSteps.map((s) => '- ' + s).join("\n")}`
482
526
  : ""}
483
527
 
528
+ ${modelingContract}
529
+
484
530
  ## Implementation
485
531
 
486
532
  > **Agent:** Create interface-only files. No implementation. Use Clean Architecture layers:
@@ -552,7 +598,6 @@ guardian_issue:
552
598
 
553
599
  file_changes:
554
600
  - "create: .pi/scripts/ci/check_${moduleId}_contracts.sh"
555
- - "create: .pi/scripts/ci/check_${moduleId}_coverage.sh"
556
601
  - "modify: .pi/scripts/ci/run_hardening_stages.sh"
557
602
  ---
558
603
 
@@ -572,10 +617,15 @@ automatic — no human review needed for routine checks.
572
617
  - Verifies all interface methods are implemented
573
618
  - Reports violations with file:line references
574
619
 
575
- ### Coverage Threshold Check
576
- - Runs the project's coverage tool
577
- - Asserts each module meets minimum coverage (default 80%)
578
- - Fails the build if coverage drops
620
+ ### Coverage Threshold Check (real project coverage)
621
+ - Coverage is enforced with the PROJECT'S REAL coverage tooling and gate
622
+ (e.g. \`cargo llvm-cov\` via a \`.pi/scripts/coverage.sh --gate\`, \`pytest
623
+ --cov\`, or \`c8\`/\`nyc\` for TS — whatever the project's CI coverage job
624
+ uses); assert the aggregate threshold the project actually enforces
625
+ - Do NOT create a per-module \`*_coverage.sh\` script — grep-based module
626
+ coverage scripts are coverage-theater (count test functions, never a
627
+ percentage); the real coverage tool's artifact (lcov/cobertura) provides
628
+ the per-file breakdown instead
579
629
 
580
630
  ### CI Integration
581
631
  Each check becomes a CI stage in the hardening pipeline — it runs automatically
@@ -586,7 +636,6 @@ on every PR. No LLM cost. No human review. Just pass or fail.
586
636
  | Script | Purpose | Location |
587
637
  |--------|---------|----------|
588
638
  | check_${moduleId}_contracts.sh | Validate contract implementation | .pi/scripts/ci/ |
589
- | check_${moduleId}_coverage.sh | Enforce coverage thresholds | .pi/scripts/ci/ |
590
639
  | stage_${moduleId}_proofing.sh | CI stage wrapper | .pi/scripts/ci/ |
591
640
 
592
641
  ## CI Pipeline Update
@@ -604,7 +653,7 @@ run_stage "11" "${moduleId}_proofing" \\
604
653
  | # | Criterion | Script |
605
654
  |---|-----------|--------|
606
655
  | 1 | All interfaces have implementations | check_contracts.sh |
607
- | 2 | Coverage ≥ 80% per module | check_coverage.sh |
656
+ | 2 | Coverage meets the project's real coverage gate (project coverage tool + threshold in CI) | project coverage job (e.g. .pi/scripts/coverage.sh --gate) |
608
657
  | 3 | CI runs checks on every PR | run_hardening_stages.sh |
609
658
  | 4 | All scripts exit 0 on pass, 1 on fail | self-validating |
610
659
 
@@ -634,6 +683,108 @@ run_stage "11" "${moduleId}_proofing" \\
634
683
 
635
684
  // ── Architecture Readiness Generator (expanded) ──
636
685
 
686
+
687
+ /**
688
+ * ADR-009: Runtime Integration & Legacy Migration issue generator.
689
+ * Modules whose doc documents runtime integration get a mandatory epic item
690
+ * (after implementation, before proofing) so "implemented but never wired"
691
+ * can no longer be deferred via an honest note.
692
+ */
693
+ export function moduleHasRuntimeIntegration(cwd: string, moduleId: string): boolean {
694
+ const doc = readModuleDoc(cwd, moduleId);
695
+ if (!doc) return false;
696
+ return /^#{2,3}\s+(Integration|Runtime|Dispatch|Wiring|.*Dispatch.*)/im.test(doc);
697
+ }
698
+
699
+ export function generateRuntimeIntegrationMarkdown(
700
+ slice: ArchitectureSlice,
701
+ epicName: string,
702
+ cwd?: string,
703
+ ): string {
704
+ const moduleId = slice.module.replace(/^module-/, "");
705
+ const doc = cwd ? readModuleDoc(cwd, slice.module) : "";
706
+ // Pull the documented integration seam(s) as guidance for the implementer.
707
+ const seam = (() => {
708
+ for (const heading of ["Integration with Existing Modules", "Runtime Integration", "Integration"]) {
709
+ const body = extractSection(doc, heading);
710
+ if (body && body.trim().length > 0) return `## ${heading}\n\n${body.trim().slice(0, 1800)}`;
711
+ }
712
+ return "";
713
+ })();
714
+
715
+ return `---
716
+ guardian_issue:
717
+ id: "ISSUE-RUNTIME-INTEGRATION"
718
+ epic: "${epicName}"
719
+ component: "Runtime Integration & Legacy Migration"
720
+ module: "${slice.module}"
721
+ status: planned
722
+ priority: high
723
+ dependencies: []
724
+
725
+ in_scope:
726
+ - Wire the new service(s) into the REAL runtime path — every choke point the module doc
727
+ documents (primary dispatch loop PLUS parallel / resume / hook variants; not just one seam)
728
+ - Remove or delegate the legacy path the module replaces (legacy symbol deleted or calling
729
+ the new service); honor any declared migration rule (e.g. GAP-N)
730
+ - Flip the feature ADR from Proposed to Accepted once wired
731
+ - Add an end-to-end test through the PUBLIC API that exercises the new path
732
+
733
+ out_of_scope:
734
+ - New feature behavior beyond wiring
735
+ - Schema/data migrations (unless the module doc's migration rule requires them)
736
+
737
+ canonical_references:
738
+ - module: ".pi/architecture/modules/${slice.module}.md"
739
+ - adr: ".pi/architecture/decisions/ADR-009-runtime-wiring-multilang-judgment.md"
740
+
741
+ acceptance_criteria:
742
+ - "New service reachable from the runtime entry path (production, non-test caller)"
743
+ - "Legacy path removed or delegating; migration rule honored"
744
+ - "Feature ADR Accepted"
745
+ - "E2E through the public API exercises the new path"
746
+ - "validate-runtime-wiring.sh passes for the module (no dead service)"
747
+
748
+ validators:
749
+ - ci
750
+ - tests
751
+ - architecture
752
+ - operations
753
+ - canonical
754
+
755
+ implementation_notes: |
756
+ Run .pi/scripts/validate-runtime-wiring.sh before and after. If the new service
757
+ is only referenced by its own tests, it is NOT integrated. Converting the
758
+ documented dispatch choke point(s) is the acceptance slice — do not close this
759
+ issue with an honest deferral note.
760
+
761
+ file_changes:
762
+ - "modify: runtime dispatch / composition root (per module doc integration section)"
763
+ ---
764
+
765
+ # Runtime Integration & Legacy Migration: ${slice.module}
766
+
767
+ ## Intent
768
+
769
+ The implementation issues above delivered tested components. This issue makes them
770
+ **actually run**: wire them into the runtime dispatch path, retire the legacy path,
771
+ and prove it with an end-to-end test — per ADR-009 (implemented-but-unwired is the
772
+ gap this closes).
773
+
774
+ ${seam || "> No explicit integration section found in the module doc — trace the runtime entry path (dispatch/composition root) yourself."}
775
+
776
+ ## Acceptance Criteria
777
+
778
+ | # | Criterion | How to Verify |
779
+ |---|-----------|---------------|
780
+ | 1 | Service reachable from runtime entry path | grep production (non-test) caller outside the module |
781
+ | 2 | Legacy path removed or delegating | legacy symbol deleted / delegates to new service |
782
+ | 3 | Feature ADR Accepted | ADR Status not Proposed |
783
+ | 4 | E2E exercises the new path through the public API | integration/e2e test |
784
+ | 5 | validate-runtime-wiring.sh passes | script exit 0 |
785
+ `;
786
+ }
787
+
637
788
  export function generateArchitectureReadinessMarkdown(
638
789
  slice: ArchitectureSlice,
639
790
  epicName: string,
@@ -353,6 +353,11 @@ export function parseModuleFile(filePath: string): ModuleComponent[] {
353
353
  let currentStatus = "";
354
354
  let currentDesc = "";
355
355
  let currentDeps: string[] = [];
356
+ let currentDepth = undefined as ModuleComponent["modelingDepth"];
357
+ let currentAggregateRoot = false;
358
+ let currentInvariants: string[] = [];
359
+ let currentPublishes: string[] = [];
360
+ let currentSubscribes: string[] = [];
356
361
 
357
362
  function saveCurrent() {
358
363
  if (currentName) {
@@ -364,6 +369,17 @@ export function parseModuleFile(filePath: string): ModuleComponent[] {
364
369
  status: status as ModuleComponent["status"],
365
370
  description: desc.trim(),
366
371
  dependencies: currentDeps.length > 0 ? currentDeps : ["none"],
372
+ ...(currentDepth ? { modelingDepth: currentDepth } : {}),
373
+ ...(currentAggregateRoot ? { aggregateRoot: true } : {}),
374
+ ...(currentInvariants.length > 0 ? { invariants: currentInvariants } : {}),
375
+ ...(currentPublishes.length > 0 || currentSubscribes.length > 0
376
+ ? {
377
+ events: {
378
+ publishes: currentPublishes,
379
+ subscribes: currentSubscribes,
380
+ },
381
+ }
382
+ : {}),
367
383
  });
368
384
  }
369
385
  }
@@ -384,6 +400,11 @@ export function parseModuleFile(filePath: string): ModuleComponent[] {
384
400
  currentStatus = "";
385
401
  currentDesc = "";
386
402
  currentDeps = [];
403
+ currentDepth = undefined;
404
+ currentAggregateRoot = false;
405
+ currentInvariants = [];
406
+ currentPublishes = [];
407
+ currentSubscribes = [];
387
408
  inComponentSection = false;
388
409
  inDetailsSection = false;
389
410
  continue;
@@ -401,6 +422,11 @@ export function parseModuleFile(filePath: string): ModuleComponent[] {
401
422
  currentStatus = "";
402
423
  currentDesc = "";
403
424
  currentDeps = [];
425
+ currentDepth = undefined;
426
+ currentAggregateRoot = false;
427
+ currentInvariants = [];
428
+ currentPublishes = [];
429
+ currentSubscribes = [];
404
430
  continue;
405
431
  }
406
432
 
@@ -413,6 +439,29 @@ export function parseModuleFile(filePath: string): ModuleComponent[] {
413
439
  if (depsStr && depsStr !== "none" && depsStr !== "[TODO") {
414
440
  currentDeps = depsStr.split(",").map((d) => d.trim()).filter(Boolean);
415
441
  }
442
+ } else if (trimmed.startsWith("modelingDepth:")) {
443
+ const v = trimmed.replace("modelingDepth:", "").trim();
444
+ if (v === "tactical" || v === "service" || v === "adapter") currentDepth = v;
445
+ } else if (trimmed.startsWith("aggregateRoot:")) {
446
+ currentAggregateRoot = trimmed.replace("aggregateRoot:", "").trim() === "true";
447
+ } else if (trimmed.startsWith("invariants:")) {
448
+ currentInvariants = trimmed
449
+ .replace("invariants:", "")
450
+ .split(";")
451
+ .map((x) => x.trim())
452
+ .filter(Boolean);
453
+ } else if (trimmed.startsWith("publishes:")) {
454
+ currentPublishes = trimmed
455
+ .replace("publishes:", "")
456
+ .split(",")
457
+ .map((x) => x.trim())
458
+ .filter(Boolean);
459
+ } else if (trimmed.startsWith("subscribes:")) {
460
+ currentSubscribes = trimmed
461
+ .replace("subscribes:", "")
462
+ .split(",")
463
+ .map((x) => x.trim())
464
+ .filter(Boolean);
416
465
  } else if (trimmed.startsWith("**Purpose:**")) {
417
466
  currentDesc = trimmed.replace(/\*\*Purpose:\*\*\s*/, "").trim();
418
467
  } else if (!currentDesc && trimmed.length > 10 && !trimmed.startsWith("#") && !trimmed.startsWith("-") && !trimmed.startsWith("|") && !trimmed.startsWith(">") && !trimmed.startsWith("```")) {
@@ -55,12 +55,16 @@ export type ExtensionContext = {
55
55
  notify(message: string, level?: string): void;
56
56
  setStatus(key: string, message: string | null): void;
57
57
  confirm(title: string, message: string): Promise<boolean>;
58
+ theme?: unknown;
58
59
  };
59
60
  tools: { execute(name: string, params: Record<string, unknown>): Promise<unknown> };
60
61
  };
61
62
 
62
63
  export type ExtensionAPI = {
63
- on(event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>): void;
64
+ on(
65
+ event: string,
66
+ handler: (event: unknown, ctx: ExtensionContext) => unknown | Promise<unknown>,
67
+ ): void;
64
68
  registerTool(options: {
65
69
  name: string;
66
70
  label: string;
@@ -70,7 +74,7 @@ export type ExtensionAPI = {
70
74
  toolCallId: string,
71
75
  params: Record<string, unknown>,
72
76
  signal: AbortSignal,
73
- onUpdate: (update: { type: string; message: string }) => void,
77
+ onUpdate: (update: { type?: string; message?: string; content?: unknown }) => void,
74
78
  ctx: ExtensionContext,
75
79
  ): unknown | Promise<unknown>;
76
80
  }): void;
@@ -96,6 +100,14 @@ export type ModuleComponent = {
96
100
  status: "planned" | "in-progress" | "implemented" | "deprecated";
97
101
  description: string;
98
102
  dependencies: string[];
103
+ /** ADR-008 modeling depth for this component's module (from exploration) */
104
+ modelingDepth?: "tactical" | "service" | "adapter";
105
+ /** True when this component is an aggregate root — slice selection keeps it atomic */
106
+ aggregateRoot?: boolean;
107
+ /** Aggregate invariants (ADR-008) — become frozen contract + TDD tests */
108
+ invariants?: string[];
109
+ /** Domain events this aggregate publishes/subscribes to (ADR-008) */
110
+ events?: { publishes: string[]; subscribes: string[] };
99
111
  };
100
112
 
101
113
  export type ArchitectureSlice = {
@@ -104,6 +116,36 @@ export type ArchitectureSlice = {
104
116
  nextLogicalSlice: ModuleComponent[];
105
117
  };
106
118
 
119
+
120
+ /** A module row inside a roadmap phase (implementation-roadmap.md). */
121
+ export interface RoadmapPhaseModule {
122
+ name: string;
123
+ deliverables: string;
124
+ doc: string;
125
+ }
126
+
127
+ /** A single delivery phase parsed from implementation-roadmap.md. */
128
+ export interface RoadmapPhase {
129
+ index: number;
130
+ title: string;
131
+ goal: string;
132
+ days: string;
133
+ modules: RoadmapPhaseModule[];
134
+ completedModules: string[];
135
+ dependencies: string[];
136
+ migrations: string[];
137
+ criteria: string[];
138
+ status: string; // "pending" | "in_progress" | "done"
139
+ }
140
+
141
+ /** Persisted roadmap execution state (.pi/.guardian-roadmap-state.json). */
142
+ export interface RoadmapState {
143
+ phases: RoadmapPhase[];
144
+ currentPhaseIndex: number;
145
+ startedAt: string;
146
+ updatedAt: string;
147
+ }
148
+
107
149
  export type EpicState = {
108
150
  name: string;
109
151
  trackingIssueId: string | null;
@@ -34,10 +34,10 @@ import {
34
34
  generateContractFreezeMarkdown,
35
35
  generateIssueMarkdown,
36
36
  generateProofingMarkdown,
37
+ generateRuntimeIntegrationMarkdown,
38
+ moduleHasRuntimeIntegration,
37
39
  detectLanguage,
38
40
  testBaseDirFromLang,
39
- testRunnerHintFromLang,
40
- implExtension,
41
41
  } from "./architect-lib/generators";
42
42
  import {
43
43
  generateEpicTestFiles,
@@ -112,7 +112,7 @@ function formatEpicStatus(state: EpicState | null): string {
112
112
  class EpicManager {
113
113
  private state: EpicState | null;
114
114
 
115
- constructor(private cwd: string) {
115
+ constructor(private readonly cwd: string) {
116
116
  this.state = loadEpicState(cwd);
117
117
  }
118
118
 
@@ -120,7 +120,8 @@ class EpicManager {
120
120
  return this.state;
121
121
  }
122
122
 
123
- async startEpic(
123
+ // NOSONAR: architect orchestrator (deliberate multi-branch dispatcher)
124
+ async startEpic( // NOSONAR: architect orchestrator — deliberate multi-branch dispatcher
124
125
  ctx: ExtensionContext,
125
126
  name: string,
126
127
  trackingIssueId?: string,
@@ -141,9 +142,33 @@ class EpicManager {
141
142
  slice = { module: matchedModule.replace(".md", ""), components, nextLogicalSlice: planned };
142
143
  }
143
144
  }
144
- // Fallback: first module with planned components
145
+ // Fallback: auto-select ONLY when unambiguous. If the epic name did not
146
+ // match a module doc and several modules still have planned components,
147
+ // error with candidates instead of silently planning the wrong module
148
+ // (previously this silently picked the first match — e.g. stale docs).
145
149
  if (!slice) {
146
- slice = findNextLogicalSlice(this.cwd, moduleFiles);
150
+ const candidates: { file: string; planned: number }[] = [];
151
+ for (const f of moduleFiles) {
152
+ const comps = parseModuleFile(join(this.cwd, ARCH_MODULES_DIR, f));
153
+ const planned = comps.filter((c: ModuleComponent) => c.status === "planned");
154
+ if (planned.length > 0) candidates.push({ file: f, planned: planned.length });
155
+ }
156
+ if (candidates.length === 1) {
157
+ const c = candidates[0];
158
+ const comps = parseModuleFile(join(this.cwd, ARCH_MODULES_DIR, c.file));
159
+ slice = {
160
+ module: c.file.replace(".md", ""),
161
+ components: comps,
162
+ nextLogicalSlice: comps.filter((x: ModuleComponent) => x.status === "planned"),
163
+ };
164
+ } else if (candidates.length > 1) {
165
+ const list = candidates
166
+ .map((c) => ` - ${c.file.replace(".md", "")} (${c.planned} planned)`)
167
+ .join("\n");
168
+ throw new Error(
169
+ `Epic name "${name}" did not match any architecture module, and ${candidates.length} modules have planned components:\n${list}\nRename the epic to match a module (e.g. /architect --epic "Module Name") or mark stale module components as implemented.`,
170
+ );
171
+ }
147
172
  }
148
173
  if (!slice) {
149
174
  throw new Error("All architecture components are implemented. No next slice found.");
@@ -283,6 +308,30 @@ class EpicManager {
283
308
  issues.push(entry);
284
309
  }
285
310
 
311
+ // 2.5 Runtime Integration & Legacy Migration (ADR-009) — mandatory when
312
+ // the module doc documents runtime integration. Placed after the
313
+ // implementation issues and before proofing so "implemented but never
314
+ // wired" cannot be deferred by a scope note.
315
+ if (moduleHasRuntimeIntegration(this.cwd, slice.module)) {
316
+ const integrationId = "issue-runtime-integration";
317
+ const integrationEntry = {
318
+ id: integrationId,
319
+ title: "Runtime Integration & Legacy Migration",
320
+ status: "planned" as string,
321
+ remoteIssueId: null as string | null,
322
+ };
323
+ const integrationMd = generateRuntimeIntegrationMarkdown(slice, name, this.cwd);
324
+ writeFileSync(join(issuesDir, `${integrationId}.md`), integrationMd);
325
+ if (hasRemote && remoteRepo) {
326
+ const result = createRemoteIssue(this.cwd, integrationEntry.title, join(issuesDir, `${integrationId}.md`), "epic,runtime-integration", remoteRepo);
327
+ if (result.success && result.issueNumber) {
328
+ integrationEntry.remoteIssueId = result.issueNumber;
329
+ if (effectiveTrackingId) linkRemoteIssue(this.cwd, result.issueNumber, effectiveTrackingId);
330
+ }
331
+ }
332
+ issues.push(integrationEntry);
333
+ }
334
+
286
335
  // 3. Proofing
287
336
  const proofingId = "issue-proofing";
288
337
  const proofingEntry = {
@@ -346,7 +395,10 @@ class EpicManager {
346
395
  `**Components:** ${slice.nextLogicalSlice.length}`,
347
396
  "",
348
397
  "## Issues",
349
- ...issues.map((i) => `- ${i.title}${i.remoteIssueId ? ` (#${i.remoteIssueId})` : ""}`),
398
+ ...issues.map((i) => {
399
+ const ref = i.remoteIssueId ? ` (#${i.remoteIssueId})` : "";
400
+ return `- ${i.title}${ref}`;
401
+ }),
350
402
  "",
351
403
  "---",
352
404
  "Auto-generated by Guardian Architect",
@@ -386,21 +438,31 @@ class EpicManager {
386
438
 
387
439
  // ── Extension ──
388
440
 
389
- export default function (pi: ExtensionAPI) {
390
- let manager: EpicManager | null = null;
391
441
 
392
- function findFlag(tokens: string[], prefix: string): string | undefined {
393
- const eqMatch = tokens.find((a) => a.startsWith(`${prefix}=`));
394
- if (eqMatch) return eqMatch.split("=").slice(1).join("=");
395
- const idx = tokens.indexOf(prefix);
396
- if (idx >= 0 && idx + 1 < tokens.length && !tokens[idx + 1].startsWith("--")) return tokens[idx + 1];
397
- return undefined;
442
+ function randomPL(): string {
443
+ const buf = new Uint8Array(2);
444
+ (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } }).crypto?.getRandomValues?.(buf);
445
+ const n = ((buf[0] << 8) | buf[1]) % 10000;
446
+ return `PL-${String(n).padStart(4, "0")}`;
447
+ }
448
+
449
+ function findFlag(tokens: string[], prefix: string): string | undefined {
450
+ const eqMatch = tokens.find((a) => a.startsWith(`${prefix}=`));
451
+ if (eqMatch) return eqMatch.split("=").slice(1).join("=");
452
+ const idx = tokens.indexOf(prefix);
453
+ if (idx >= 0 && idx + 1 < tokens.length && !tokens[idx + 1].startsWith("--")) return tokens[idx + 1];
454
+ return undefined;
398
455
  }
399
456
 
457
+ export default function architectExtension(pi: ExtensionAPI) {
458
+ let manager: EpicManager | null = null;
459
+
460
+
400
461
  pi.registerCommand("architect", {
401
462
  description: "Orchestrate the full architecture-to-implementation process",
402
- handler: async (args, ctx) => {
403
- if (!manager) manager = new EpicManager(ctx.cwd);
463
+ // NOSONAR: architect command dispatcher (deliberate multi-branch router)
464
+ handler: async function architectHandler(args: unknown, ctx: ExtensionContext) { // NOSONAR: architect command router — deliberate multi-branch dispatcher
465
+ manager ??= new EpicManager(ctx.cwd);
404
466
  const raw = typeof args === "string" ? args : "";
405
467
  const tokens = raw ? parseArgs(raw) : [];
406
468
  if (tokens.length === 0) {
@@ -455,7 +517,7 @@ export default function (pi: ExtensionAPI) {
455
517
  }
456
518
 
457
519
  if (tokens[0] === "--phase" || action === "phase") {
458
- const phaseName = (tokens.slice(1).join(" ") || findFlag(tokens, "--phase")).replace(/["']/g, "").trim();
520
+ const phaseName = (tokens.slice(1).join(" ") || findFlag(tokens, "--phase") || "").replace(/["']/g, "").trim();
459
521
  if (!phaseName) {
460
522
  ctx.ui.notify('Usage: /architect --phase "Phase 1"', "error");
461
523
  return;
@@ -469,9 +531,9 @@ export default function (pi: ExtensionAPI) {
469
531
 
470
532
  // Find phase by name or index
471
533
  let targetPhase: RoadmapPhase | undefined;
472
- const idxMatch = phaseName.match(/Phase\s+(\d+)/i);
534
+ const idxMatch = /Phase\s+(\d+)/i.exec(phaseName);
473
535
  if (idxMatch) {
474
- targetPhase = phases.find((p) => p.index === parseInt(idxMatch[1], 10));
536
+ targetPhase = phases.find((p) => p.index === Number.parseInt(idxMatch[1], 10));
475
537
  } else {
476
538
  targetPhase = phases.find((p) => p.title.toLowerCase().includes(phaseName.toLowerCase()));
477
539
  }
@@ -486,7 +548,7 @@ export default function (pi: ExtensionAPI) {
486
548
  if (dep.toLowerCase() === "none") return false;
487
549
  const depMatch = dep.match(/Phase\s+(\d+)/i);
488
550
  if (!depMatch) return false;
489
- const depPhase = phases.find((p) => p.index === parseInt(depMatch[1], 10));
551
+ const depPhase = phases.find((p) => p.index === Number.parseInt(depMatch[1], 10));
490
552
  return depPhase && depPhase.status !== "done";
491
553
  });
492
554
  if (unmetDeps.length > 0) {
@@ -541,7 +603,7 @@ export default function (pi: ExtensionAPI) {
541
603
  }
542
604
  const pipelineFile = join(ctx.cwd, ".pi/.guardian-pipeline-state.json");
543
605
  if (!existsSync(pipelineFile) && existingIssues.length > 0) {
544
- const pipelineId = `PL-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}`;
606
+ const pipelineId = randomPL();
545
607
  const pipelineState = {
546
608
  id: pipelineId,
547
609
  name: mod.name,
@@ -583,10 +645,11 @@ export default function (pi: ExtensionAPI) {
583
645
  const rs = loadRoadmapState(ctx.cwd) || {
584
646
  phases: [], currentPhaseIndex: 0, startedAt: "", updatedAt: "",
585
647
  };
586
- const comp = new Set((rs.phases.find((p) => p.index === targetPhase.index)?.completedModules || []));
587
- comp.add(mod.name);
588
- if (rs.phases.find((p) => p.index === targetPhase.index)) {
589
- rs.phases.find((p) => p.index === targetPhase.index)!.completedModules = Array.from(comp);
648
+ const roadmapPhase = rs.phases.find((p) => p.index === targetPhase.index);
649
+ if (roadmapPhase) {
650
+ const comp = new Set(roadmapPhase.completedModules || []);
651
+ comp.add(mod.name);
652
+ roadmapPhase.completedModules = Array.from(comp);
590
653
  }
591
654
  rs.updatedAt = new Date().toISOString();
592
655
  saveRoadmapState(ctx.cwd, rs);
@@ -594,13 +657,13 @@ export default function (pi: ExtensionAPI) {
594
657
  }
595
658
  try {
596
659
  const state = await manager.startEpic(ctx, mod.name);
597
- if (!state || !state.slices || state.slices.length === 0) {
660
+ if (!state?.slices?.length) {
598
661
  results.push(`⚠️ ${mod.name} — no architecture components found`);
599
662
  continue;
600
663
  }
601
664
  const items = (state.issues || []).map((i: { id: string }) => i.id);
602
665
  // Create pipeline state for this epic
603
- const pipelineId = `PL-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}`;
666
+ const pipelineId = randomPL();
604
667
  const pipelineState = {
605
668
  id: pipelineId,
606
669
  name: mod.name,
@@ -674,8 +737,8 @@ export default function (pi: ExtensionAPI) {
674
737
  }
675
738
 
676
739
  if (tokens[0] === "--phase-module-done" || action === "phase-module-done") {
677
- const phaseIdx = parseInt(tokens[1] || "", 10);
678
- if (isNaN(phaseIdx) || !tokens[2]) {
740
+ const phaseIdx = Number.parseInt(tokens[1] || "", 10);
741
+ if (Number.isNaN(phaseIdx) || !tokens[2]) {
679
742
  ctx.ui.notify('Usage: /architect --phase-module-done <phase-number> "<module-name>"', "error");
680
743
  return;
681
744
  }
@@ -717,8 +780,8 @@ export default function (pi: ExtensionAPI) {
717
780
  }
718
781
 
719
782
  if (tokens[0] === "--phase-done" || action === "phase-done") {
720
- const phaseIdx = parseInt(tokens[1] || "", 10);
721
- if (isNaN(phaseIdx)) {
783
+ const phaseIdx = Number.parseInt(tokens[1] || "", 10);
784
+ if (Number.isNaN(phaseIdx)) {
722
785
  ctx.ui.notify('Usage: /architect --phase-done <phase-number>', "error");
723
786
  return;
724
787
  }
@@ -782,7 +845,7 @@ export default function (pi: ExtensionAPI) {
782
845
 
783
846
  const state = await manager.startEpic(ctx, epicName, trackingIssueId, tddEnabled);
784
847
 
785
- if (!state || !state.slices || state.slices.length === 0) {
848
+ if (!state?.slices?.length) {
786
849
  ctx.ui.notify("Failed to discover architecture components. Check .pi/architecture/modules/.", "error");
787
850
  return;
788
851
  }
@@ -810,7 +873,7 @@ export default function (pi: ExtensionAPI) {
810
873
  } catch { /* ignore */ }
811
874
 
812
875
  // Write pipeline state directly (ctx.tools not available in command handlers)
813
- const pipelineId = `PL-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}`;
876
+ const pipelineId = randomPL();
814
877
  const pipelineState = {
815
878
  id: pipelineId,
816
879
  name: epicName,
@@ -841,7 +904,7 @@ export default function (pi: ExtensionAPI) {
841
904
  : "";
842
905
 
843
906
  const firstItem = items[0];
844
- const issueFilename = `${firstItem}.md`.replace(/\//g, "-");
907
+ const issueFilename = `${firstItem}.md`.replaceAll("/", "-");
845
908
  const issuePath = join(ctx.cwd, ".pi/issues", issueFilename);
846
909
 
847
910
  let issueContent = "";
@@ -904,7 +967,7 @@ export default function (pi: ExtensionAPI) {
904
967
  description: "Show the current epic status and progress.",
905
968
  parameters: { type: "object", properties: {} },
906
969
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
907
- if (!manager) manager = new EpicManager(ctx.cwd);
970
+ manager ??= new EpicManager(ctx.cwd);
908
971
  const state = manager.getState();
909
972
  return { content: [{ type: "text", text: formatEpicStatus(state) }] };
910
973
  },
@@ -924,18 +987,15 @@ export default function (pi: ExtensionAPI) {
924
987
  for (const file of moduleFiles) {
925
988
  const components = parseModuleFile(join(ctx.cwd, ".pi/architecture/modules", file));
926
989
  const planned = components.filter((c) => c.status === "planned");
927
- lines.push(`### ${file.replace(".md", "")}`);
928
- lines.push(` Components: ${components.length} (${planned.length} planned)`);
990
+ lines.push(`### ${file.replace(".md", "")}`, ` Components: ${components.length} (${planned.length} planned)`);
929
991
  if (planned.length > 0) {
930
- lines.push(" Next slice:");
931
- for (const c of planned) lines.push(` - ${c.name}`);
992
+ lines.push(" Next slice:", ...planned.map((c: ModuleComponent) => ` - ${c.name}`));
932
993
  }
933
994
  lines.push("");
934
995
  }
935
996
  const slice = findNextLogicalSlice(ctx.cwd, moduleFiles);
936
997
  if (slice) {
937
- lines.push(`**Recommended next epic:** ${slice.module}`);
938
- lines.push(`Components: ${slice.nextLogicalSlice.map((c: ModuleComponent) => c.name).join(", ")}`);
998
+ lines.push(`**Recommended next epic:** ${slice.module}`, `Components: ${slice.nextLogicalSlice.map((c: ModuleComponent) => c.name).join(", ")}`);
939
999
  }
940
1000
  return { content: [{ type: "text", text: lines.join("\n") }] };
941
1001
  },