@geminix/gxpm 0.1.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.
Files changed (299) hide show
  1. package/AGENTS.md +148 -0
  2. package/CANON.md +53 -0
  3. package/CLAUDE.md +60 -0
  4. package/CONTEXT.md +49 -0
  5. package/DEBUG.md +59 -0
  6. package/ISSUE_CONTEXT.md +25 -0
  7. package/README.md +143 -0
  8. package/VERSION +1 -0
  9. package/agents/cleanup-auditor/cleanup-auditor.md +56 -0
  10. package/agents/grill-master.md +26 -0
  11. package/agents/implementer.md +32 -0
  12. package/agents/review-army/accessibility-reviewer.md +54 -0
  13. package/agents/review-army/code-quality-reviewer.md +54 -0
  14. package/agents/review-army/security-reviewer.md +56 -0
  15. package/agents/review-army/spec-compliance-reviewer.md +51 -0
  16. package/agents/review-army/test-reviewer.md +55 -0
  17. package/agents/reviewer.md +59 -0
  18. package/agents/ship-audit-army/docs-auditor.md +53 -0
  19. package/agents/ship-audit-army/performance-auditor.md +52 -0
  20. package/agents/ship-audit-army/security-auditor.md +52 -0
  21. package/agents/specifier.md +55 -0
  22. package/agents/triage-officer.md +27 -0
  23. package/bin/gxpm +17 -0
  24. package/bin/gxpm-browser +17 -0
  25. package/bin/gxpm-config +15 -0
  26. package/bin/gxpm-eval +13 -0
  27. package/bin/gxpm-global-discover +15 -0
  28. package/bin/gxpm-init +38 -0
  29. package/bin/gxpm-investigate +194 -0
  30. package/bin/gxpm-uninstall +15 -0
  31. package/bin/gxpm-update-check +165 -0
  32. package/commands/build.md +40 -0
  33. package/commands/help.md +53 -0
  34. package/commands/plan.md +34 -0
  35. package/commands/refine.md +46 -0
  36. package/commands/review.md +34 -0
  37. package/commands/ship.md +37 -0
  38. package/core/ac-check.ts +20 -0
  39. package/core/agent-runtime.ts +363 -0
  40. package/core/artifact-validator.ts +151 -0
  41. package/core/artifacts.ts +313 -0
  42. package/core/autopilot.ts +250 -0
  43. package/core/capabilities.ts +779 -0
  44. package/core/checkpoint.ts +370 -0
  45. package/core/cleanup.ts +32 -0
  46. package/core/command-probe.ts +82 -0
  47. package/core/config.ts +533 -0
  48. package/core/contracts/behavior-spec.schema.ts +38 -0
  49. package/core/contracts/converter.ts +61 -0
  50. package/core/contracts/host.ts +43 -0
  51. package/core/converters/converter.ts +93 -0
  52. package/core/converters/index.ts +8 -0
  53. package/core/converters/managed-artifact.ts +119 -0
  54. package/core/converters/parser.ts +159 -0
  55. package/core/converters/template-renderer.ts +35 -0
  56. package/core/converters/writer.ts +61 -0
  57. package/core/dag-executor.ts +426 -0
  58. package/core/dag-loader.ts +292 -0
  59. package/core/dag-schemas.ts +150 -0
  60. package/core/dispatch.ts +125 -0
  61. package/core/evidence.ts +148 -0
  62. package/core/gate.ts +269 -0
  63. package/core/hook-engine.ts +566 -0
  64. package/core/host-probe.ts +64 -0
  65. package/core/implement.ts +16 -0
  66. package/core/isolation-errors.ts +174 -0
  67. package/core/isolation-resolver.ts +921 -0
  68. package/core/issue-context.ts +381 -0
  69. package/core/issue-readiness.ts +457 -0
  70. package/core/issue-sync.ts +427 -0
  71. package/core/issues.ts +132 -0
  72. package/core/land.ts +108 -0
  73. package/core/orchestrator.ts +54 -0
  74. package/core/phase-artifact.ts +32 -0
  75. package/core/phase-gates.ts +130 -0
  76. package/core/phase-rewind.ts +94 -0
  77. package/core/plan-lint.ts +61 -0
  78. package/core/plan.ts +77 -0
  79. package/core/port-allocation.ts +50 -0
  80. package/core/pr-check.ts +15 -0
  81. package/core/preset-system/preset-resolver.ts +221 -0
  82. package/core/project-init-status.ts +127 -0
  83. package/core/qa.ts +15 -0
  84. package/core/resilience.ts +165 -0
  85. package/core/runs.ts +288 -0
  86. package/core/safe-path.test.ts +80 -0
  87. package/core/safe-path.ts +60 -0
  88. package/core/sdd-gate.test.ts +98 -0
  89. package/core/sdd-gate.ts +134 -0
  90. package/core/self-review.ts +62 -0
  91. package/core/session.ts +70 -0
  92. package/core/ship.ts +86 -0
  93. package/core/specify.ts +173 -0
  94. package/core/state.ts +1002 -0
  95. package/core/template-engine.ts +152 -0
  96. package/core/template-resolver.test.ts +70 -0
  97. package/core/template-resolver.ts +156 -0
  98. package/core/triage.ts +26 -0
  99. package/core/verify.ts +15 -0
  100. package/core/wiki-native.ts +2423 -0
  101. package/core/wiki.ts +27 -0
  102. package/core/workflow-event-emitter.ts +163 -0
  103. package/core/workflows/engine.ts +273 -0
  104. package/core/workflows/expressions.ts +76 -0
  105. package/core/workflows/index.ts +38 -0
  106. package/core/workflows/steps/command.ts +43 -0
  107. package/core/workflows/steps/gate.ts +47 -0
  108. package/core/workflows/steps/gxpm.ts +44 -0
  109. package/core/workflows/steps/linear.ts +31 -0
  110. package/core/workflows/steps/shell.ts +65 -0
  111. package/core/workflows/types.ts +62 -0
  112. package/core/workspace-runtime.ts +227 -0
  113. package/core/worktree-init-steps.ts +647 -0
  114. package/core/worktree-init.ts +330 -0
  115. package/core/worktree-owner.ts +143 -0
  116. package/docs/GXPM_VERIFY.md +98 -0
  117. package/docs/INSTALL_FOR_AGENTS.md +113 -0
  118. package/docs/README.md +57 -0
  119. package/docs/adr/adr-005-multi-platform-skill-converter.md +72 -0
  120. package/docs/agents/domain.md +30 -0
  121. package/docs/agents/issue-tracker.md +30 -0
  122. package/docs/agents/triage-labels.md +32 -0
  123. package/docs/architecture/gxpm-architecture-diagram.md +265 -0
  124. package/docs/architecture/gxpm-current-architecture.md +175 -0
  125. package/docs/architecture/gxpm-current-flow.md +278 -0
  126. package/docs/architecture/gxpm-replacement-architecture.md +211 -0
  127. package/docs/architecture/gxpm-target-architecture.md +449 -0
  128. package/docs/architecture/gxpm-v0-contract.md +311 -0
  129. package/docs/architecture/layered-workflow-boundaries.md +193 -0
  130. package/docs/architecture/preset-system.md +126 -0
  131. package/docs/architecture/scaffold-northstar.md +23 -0
  132. package/docs/brainstorms/2026-05-14-bdd-then-tdd-design.md +320 -0
  133. package/docs/brainstorms/README.md +22 -0
  134. package/docs/brainstorms/docs-knowledge-system-requirements.md +29 -0
  135. package/docs/governance/beta-skill-promotion.md +39 -0
  136. package/docs/governance/development-contract.md +144 -0
  137. package/docs/governance/gherkin-style.md +90 -0
  138. package/docs/governance/host-adapter.md +56 -0
  139. package/docs/governance/skill-authoring.md +87 -0
  140. package/docs/governance/skill-testing.md +356 -0
  141. package/docs/governance/template-authoring.md +53 -0
  142. package/docs/migrations/v0.2.md +51 -0
  143. package/docs/plans/README.md +23 -0
  144. package/docs/plans/bdd-then-tdd-plan.md +1767 -0
  145. package/docs/plans/docs-knowledge-system-plan.md +31 -0
  146. package/docs/plans/spec-kit-sdd-adoption-plan.md +305 -0
  147. package/docs/research/agents-md-best-practices.md +207 -0
  148. package/docs/research/archon-study.md +351 -0
  149. package/docs/research/claude-hooks-study.md +440 -0
  150. package/docs/research/codex-hooks-study.md +624 -0
  151. package/docs/research/everything-claude-code-study.md +252 -0
  152. package/docs/research/from-skills-to-layered-workflow.md +322 -0
  153. package/docs/research/gsd-study.md +69 -0
  154. package/docs/research/kimi-hooks-study.md +274 -0
  155. package/docs/research/mattpocock-skills-comparison.md +429 -0
  156. package/docs/research/mattpocock-skills-study.md +275 -0
  157. package/docs/research/oh-my-codex-study.md +279 -0
  158. package/docs/research/perplexity-agent-skills-design.md +168 -0
  159. package/docs/research/pmc-gstack-skill-study.md +122 -0
  160. package/docs/research/spec-kit-study.md +224 -0
  161. package/docs/research/superpowers-study.md +209 -0
  162. package/docs/roadmap/initial-roadmap.md +53 -0
  163. package/docs/solutions/README.md +45 -0
  164. package/docs/solutions/artifact-nesting-recovery.md +58 -0
  165. package/docs/solutions/session-context-restore-practice.md +67 -0
  166. package/docs/solutions/workflow/version-drift-recovery.md +49 -0
  167. package/docs/solutions/worktree-gate-recovery.md +62 -0
  168. package/docs/specs/README.md +28 -0
  169. package/docs/specs/claude.md +45 -0
  170. package/docs/specs/codex.md +44 -0
  171. package/docs/specs/cursor.md +44 -0
  172. package/hosts/adapters/claude.ts +29 -0
  173. package/hosts/adapters/codex.ts +27 -0
  174. package/hosts/adapters/cursor.ts +27 -0
  175. package/hosts/adapters/kimi.ts +27 -0
  176. package/hosts/claude.ts +23 -0
  177. package/hosts/codex.ts +26 -0
  178. package/hosts/cursor.ts +19 -0
  179. package/hosts/index.ts +33 -0
  180. package/hosts/registry.test.ts +52 -0
  181. package/hosts/registry.ts +57 -0
  182. package/hosts/schema.ts +58 -0
  183. package/package.json +52 -0
  184. package/scripts/browser.ts +185 -0
  185. package/scripts/cleanup.ts +142 -0
  186. package/scripts/commands/artifact.ts +115 -0
  187. package/scripts/commands/autopilot.ts +143 -0
  188. package/scripts/commands/capability.ts +57 -0
  189. package/scripts/commands/config.ts +69 -0
  190. package/scripts/commands/dag.ts +126 -0
  191. package/scripts/commands/feedback.ts +123 -0
  192. package/scripts/commands/gate.ts +291 -0
  193. package/scripts/commands/helpers.ts +126 -0
  194. package/scripts/commands/hook.ts +66 -0
  195. package/scripts/commands/init.ts +515 -0
  196. package/scripts/commands/issue.ts +825 -0
  197. package/scripts/commands/phase.ts +61 -0
  198. package/scripts/commands/preset.ts +159 -0
  199. package/scripts/commands/runtime.ts +199 -0
  200. package/scripts/commands/specify.ts +71 -0
  201. package/scripts/commands/upgrade.ts +243 -0
  202. package/scripts/commands/verify.ts +183 -0
  203. package/scripts/commands/wiki.ts +242 -0
  204. package/scripts/commands/workflow.ts +131 -0
  205. package/scripts/dev-skill.ts +55 -0
  206. package/scripts/discover-skills.ts +116 -0
  207. package/scripts/doctor.ts +410 -0
  208. package/scripts/dogfood-check.ts +125 -0
  209. package/scripts/eval-functional.ts +218 -0
  210. package/scripts/eval.ts +246 -0
  211. package/scripts/gen-skill-docs.ts +201 -0
  212. package/scripts/global-discover.ts +217 -0
  213. package/scripts/governance-check.ts +75 -0
  214. package/scripts/gxpm-check.ts +12 -0
  215. package/scripts/gxpm.ts +216 -0
  216. package/scripts/host-config.ts +62 -0
  217. package/scripts/install-claude-hooks.ts +138 -0
  218. package/scripts/install-codex-hooks.ts +271 -0
  219. package/scripts/install-hooks.ts +128 -0
  220. package/scripts/install-kimi-hooks.ts +92 -0
  221. package/scripts/install-skill.ts +184 -0
  222. package/scripts/phase-artifact-commands.ts +100 -0
  223. package/scripts/post-land-sync.ts +46 -0
  224. package/scripts/scaffold-check.ts +85 -0
  225. package/scripts/skill-naming-check.ts +78 -0
  226. package/scripts/skill-structure-check.ts +157 -0
  227. package/scripts/skills-lock-check.ts +60 -0
  228. package/scripts/sync-markdown-artifacts.ts +172 -0
  229. package/scripts/uninstall.ts +162 -0
  230. package/scripts/version.ts +47 -0
  231. package/scripts/wait-pr-ready.ts +407 -0
  232. package/skills/gxpm/SKILL.md +485 -0
  233. package/skills/gxpm/SKILL.md.tmpl +422 -0
  234. package/skills/gxpm/references/CANON.md +53 -0
  235. package/skills/gxpm/references/key-rules.md +130 -0
  236. package/skills/gxpm-architecture/SKILL.md +106 -0
  237. package/skills/gxpm-architecture/references/DEEPENING.md +37 -0
  238. package/skills/gxpm-architecture/references/INTERFACE-DESIGN.md +44 -0
  239. package/skills/gxpm-autopilot/SKILL.md +116 -0
  240. package/skills/gxpm-autopilot/SKILL.md.tmpl +107 -0
  241. package/skills/gxpm-browser/SKILL.md +105 -0
  242. package/skills/gxpm-browser/SKILL.md.tmpl +41 -0
  243. package/skills/gxpm-browser/references/commands.md +43 -0
  244. package/skills/gxpm-browser/references/evidence-path.md +20 -0
  245. package/skills/gxpm-build/SKILL.md +78 -0
  246. package/skills/gxpm-cleanup/SKILL.md +76 -0
  247. package/skills/gxpm-debug-issue/SKILL.md +39 -0
  248. package/skills/gxpm-diagnose/SKILL.md +220 -0
  249. package/skills/gxpm-diagnose/SKILL.md.tmpl +31 -0
  250. package/skills/gxpm-diagnose/references/feedback-loop.md +34 -0
  251. package/skills/gxpm-diagnose/references/feedback-loops.md +43 -0
  252. package/skills/gxpm-diagnose/references/phases.md +60 -0
  253. package/skills/gxpm-eval/SKILL.md +78 -0
  254. package/skills/gxpm-explore-codebase/SKILL.md +36 -0
  255. package/skills/gxpm-explore-codebase/scripts/summarize-communities.ts +51 -0
  256. package/skills/gxpm-feedback/SKILL.md +122 -0
  257. package/skills/gxpm-grill/SKILL.md +159 -0
  258. package/skills/gxpm-grill/SKILL.md.tmpl +77 -0
  259. package/skills/gxpm-grill/references/documentation-templates.md +56 -0
  260. package/skills/gxpm-grill/references/process.md +25 -0
  261. package/skills/gxpm-handoff/SKILL.md +112 -0
  262. package/skills/gxpm-hygiene/SKILL.md +69 -0
  263. package/skills/gxpm-implementer/SKILL.md +142 -0
  264. package/skills/gxpm-implementer/SKILL.md.tmpl +141 -0
  265. package/skills/gxpm-linear/SKILL.md +282 -0
  266. package/skills/gxpm-linear/SKILL.md.tmpl +86 -0
  267. package/skills/gxpm-linear/references/commands.md +75 -0
  268. package/skills/gxpm-linear/references/workflows.md +120 -0
  269. package/skills/gxpm-planning/SKILL.md +134 -0
  270. package/skills/gxpm-prototype/SKILL.md +64 -0
  271. package/skills/gxpm-refactor-safely/SKILL.md +62 -0
  272. package/skills/gxpm-review-army/SKILL.md +117 -0
  273. package/skills/gxpm-review-changes/SKILL.md +36 -0
  274. package/skills/gxpm-setup/SKILL.md +101 -0
  275. package/skills/gxpm-specifier/SKILL.md +135 -0
  276. package/skills/gxpm-tdd/SKILL.md +187 -0
  277. package/skills/gxpm-tdd/references/interface-design.md +23 -0
  278. package/skills/gxpm-tdd/references/mocking.md +27 -0
  279. package/skills/gxpm-tdd/references/red-green-refactor.md +61 -0
  280. package/skills/gxpm-tdd/references/troubleshooting.md +28 -0
  281. package/skills/gxpm-tdd/references/workflow.md +50 -0
  282. package/skills/gxpm-tdd/testing-anti-patterns.tmpl +304 -0
  283. package/skills/gxpm-triage/SKILL.md +160 -0
  284. package/skills/gxpm-verify/SKILL.md +107 -0
  285. package/skills/gxpm-write-skill/SKILL.md +131 -0
  286. package/skills/gxpm-zoom-out/SKILL.md +69 -0
  287. package/skills/maintain-hygiene-skills-lock/SKILL.md +54 -0
  288. package/skills/maintain-hygiene-skills-lock/SKILL.md.tmpl +53 -0
  289. package/templates/constitution-template.md +63 -0
  290. package/templates/hooks/gxpm-commit-msg +16 -0
  291. package/templates/hooks/gxpm-post-checkout +19 -0
  292. package/templates/hooks/gxpm-post-commit +7 -0
  293. package/templates/hooks/gxpm-post-merge +29 -0
  294. package/templates/hooks/gxpm-pre-commit +39 -0
  295. package/templates/hooks/gxpm-pre-push +33 -0
  296. package/templates/plan-template.md.tmpl +46 -0
  297. package/templates/spec-template.md.tmpl +63 -0
  298. package/templates/specify-stub.tmpl +22 -0
  299. package/templates/tasks-template.md.tmpl +32 -0
package/core/state.ts ADDED
@@ -0,0 +1,1002 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { getGateCommand, getRequiredArtifactForTransition } from "./phase-gates";
11
+ import { resolveAgentIdentity, resolveSessionId } from "./session";
12
+ import { getWorkflowEventEmitter } from "./workflow-event-emitter";
13
+ import { getResolvedConfigValue } from "./config";
14
+
15
+ export const CURRENT_SCHEMA_VERSION = 1;
16
+
17
+ // Issues whose phaseHistory shows implement entered before this cutoff are
18
+ // exempt from the specify-gate (introduced on this date). Do NOT change
19
+ // retroactively — that would break legacy issues.
20
+ export const SPECIFY_PHASE_CUTOFF = "2026-05-14T00:00:00Z";
21
+
22
+ export const GXPM_PHASES = [
23
+ "triage",
24
+ "plan",
25
+ "dispatch",
26
+ "specify",
27
+ "implement",
28
+ "local-verify",
29
+ "ac-check",
30
+ "self-review",
31
+ "cleanup",
32
+ "ship",
33
+ "pr-check",
34
+ "verify",
35
+ "qa",
36
+ "land",
37
+ ] as const;
38
+
39
+ export type GxpmPhase = (typeof GXPM_PHASES)[number];
40
+
41
+ export const ISSUE_TYPES = ["feature", "meta", "spike"] as const;
42
+
43
+ export type IssueType = (typeof ISSUE_TYPES)[number];
44
+
45
+ export const RIGOR_LEVELS = ["lite", "standard", "full"] as const;
46
+
47
+ export type RigorLevel = (typeof RIGOR_LEVELS)[number];
48
+
49
+ export interface IssueOwnershipHistoryEntry {
50
+ sessionId: string;
51
+ firstTouch: string;
52
+ lastTouch: string;
53
+ }
54
+
55
+ export interface IssueOwnership {
56
+ currentSession: string;
57
+ lastTouchedAt: string;
58
+ history: IssueOwnershipHistoryEntry[];
59
+ }
60
+
61
+ export interface IssueCreator {
62
+ host: string;
63
+ sessionId: string;
64
+ actor: string;
65
+ createdAt: string;
66
+ }
67
+
68
+ export interface IssueRelation {
69
+ relation: "parent" | "child" | "related";
70
+ issueId: string;
71
+ createdAt: string;
72
+ }
73
+
74
+ interface BaseIssueClaim {
75
+ actor: string;
76
+ claimedBySession: string;
77
+ claimedAt: string;
78
+ runId?: string;
79
+ }
80
+
81
+ export interface ClaimedIssueClaim extends BaseIssueClaim {
82
+ status: "claimed";
83
+ }
84
+
85
+ export interface ReleasedIssueClaim extends BaseIssueClaim {
86
+ status: "released";
87
+ releasedAt: string;
88
+ releasedBySession: string;
89
+ releaseReason: string;
90
+ }
91
+
92
+ export interface StaleIssueClaim extends BaseIssueClaim {
93
+ status: "stale";
94
+ staleAt: string;
95
+ staleReason: string;
96
+ }
97
+
98
+ export type IssueClaim = ClaimedIssueClaim | ReleasedIssueClaim | StaleIssueClaim;
99
+
100
+ export interface IssueState {
101
+ schemaVersion: 1;
102
+ issueId: string;
103
+ issueType?: IssueType;
104
+ rigorLevel?: RigorLevel;
105
+ currentPhase: GxpmPhase;
106
+ createdAt: string;
107
+ updatedAt: string;
108
+ stateRoot: string;
109
+ artifactRoot: string;
110
+ creator?: IssueCreator;
111
+ ownership?: IssueOwnership;
112
+ claim?: IssueClaim;
113
+ relations?: IssueRelation[];
114
+ archived?: boolean;
115
+ archivedAt?: string | null;
116
+ phaseHistory: Array<{
117
+ phase: GxpmPhase;
118
+ enteredAt: string;
119
+ fromPhase: GxpmPhase | null;
120
+ }>;
121
+ }
122
+
123
+ export interface StateEvent {
124
+ schemaVersion: 1;
125
+ type:
126
+ | "issue.created"
127
+ | "phase.transitioned"
128
+ | "phase.rewound"
129
+ | "artifact.written"
130
+ | "artifact.reconciled"
131
+ | "checkpoint.written"
132
+ | "gate.blocked"
133
+ | "gate.passed"
134
+ | "issue.claimed"
135
+ | "issue.claim.released"
136
+ | "issue.claim.stale"
137
+ | "cleanup.executed"
138
+ | "gate.brainstorm.skipped"
139
+ | "ownership.changed";
140
+ issueId: string;
141
+ timestamp: string;
142
+ sessionId?: string;
143
+ payload: Record<string, unknown>;
144
+ }
145
+
146
+ type RawIssueState = Partial<IssueState> & {
147
+ schemaVersion?: number;
148
+ issueId?: unknown;
149
+ issueType?: unknown;
150
+ currentPhase?: unknown;
151
+ createdAt?: unknown;
152
+ updatedAt?: unknown;
153
+ stateRoot?: unknown;
154
+ artifactRoot?: unknown;
155
+ claim?: unknown;
156
+ archived?: unknown;
157
+ archivedAt?: unknown;
158
+ phaseHistory?: unknown;
159
+ };
160
+
161
+ interface IssueInput {
162
+ root?: string;
163
+ issueId: string;
164
+ issueType?: IssueType;
165
+ }
166
+
167
+ interface TransitionInput extends IssueInput {
168
+ nextPhase: GxpmPhase | string;
169
+ skipCleanup?: boolean;
170
+ }
171
+
172
+ const ISSUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
173
+
174
+ export function getIssuePaths(root = process.cwd(), issueId: string) {
175
+ assertValidIssueId(issueId);
176
+ const issueRoot = join(".gxpm", "issues", issueId);
177
+ const issueDir = join(root, issueRoot);
178
+ const artifactRoot = join(issueRoot, "artifacts");
179
+
180
+ return {
181
+ issueRoot,
182
+ issueDir,
183
+ artifactRoot,
184
+ statePath: join(issueDir, "state.json"),
185
+ graphPath: join(issueDir, "graph.json"),
186
+ artifactIndexPath: join(issueDir, "artifacts", "index.json"),
187
+ eventsPath: join(issueDir, "events.jsonl"),
188
+ };
189
+ }
190
+
191
+ export function createIssueState(input: IssueInput): IssueState {
192
+ const root = input.root ?? process.cwd();
193
+ const paths = getIssuePaths(root, input.issueId);
194
+
195
+ if (existsSync(paths.statePath)) {
196
+ throw new Error(`Issue state already exists: ${input.issueId}`);
197
+ }
198
+
199
+ mkdirSync(join(paths.issueDir, "artifacts"), { recursive: true });
200
+ mkdirSync(join(paths.issueDir, "reports"), { recursive: true });
201
+ mkdirSync(join(paths.issueDir, "evidence", "screenshots"), { recursive: true });
202
+ mkdirSync(join(paths.issueDir, "memory"), { recursive: true });
203
+
204
+ const now = new Date().toISOString();
205
+ const sessionId = resolveSessionId();
206
+ const agent = resolveAgentIdentity(process.env, root);
207
+ const defaultRigor = (input.issueType === "spike" || input.issueType === "meta") ? "lite" : "standard";
208
+ const state: IssueState = {
209
+ schemaVersion: 1,
210
+ issueId: input.issueId,
211
+ issueType: input.issueType ?? "feature",
212
+ rigorLevel: defaultRigor,
213
+ currentPhase: "triage",
214
+ createdAt: now,
215
+ updatedAt: now,
216
+ stateRoot: paths.issueRoot,
217
+ artifactRoot: paths.artifactRoot,
218
+ creator: {
219
+ host: agent.host,
220
+ sessionId: agent.sessionId,
221
+ actor: agent.actor,
222
+ createdAt: now,
223
+ },
224
+ ownership: {
225
+ currentSession: sessionId,
226
+ lastTouchedAt: now,
227
+ history: [{ sessionId, firstTouch: now, lastTouch: now }],
228
+ },
229
+ phaseHistory: [{ phase: "triage", enteredAt: now, fromPhase: null }],
230
+ };
231
+
232
+ writeJson(paths.statePath, state);
233
+ writeJson(paths.graphPath, {
234
+ schemaVersion: 1,
235
+ issueId: input.issueId,
236
+ phases: GXPM_PHASES,
237
+ currentPhase: "triage",
238
+ transitions: [{ fromPhase: null, toPhase: "triage", timestamp: now }],
239
+ });
240
+ writeJson(paths.artifactIndexPath, {
241
+ schemaVersion: 1,
242
+ issueId: input.issueId,
243
+ artifacts: [],
244
+ });
245
+ appendIssueEvent({
246
+ issueDir: paths.issueDir,
247
+ event: {
248
+ schemaVersion: 1,
249
+ type: "issue.created",
250
+ issueId: input.issueId,
251
+ timestamp: now,
252
+ sessionId,
253
+ payload: { initialPhase: "triage", issueType: state.issueType },
254
+ },
255
+ });
256
+
257
+ getWorkflowEventEmitter().emit({
258
+ type: "issue_created",
259
+ issueId: input.issueId,
260
+ issueType: state.issueType,
261
+ timestamp: now,
262
+ });
263
+
264
+ fireAndForgetSync(root, input.issueId, "created", { issueType: state.issueType });
265
+
266
+ return state;
267
+ }
268
+
269
+ export function readIssueState(input: IssueInput): IssueState {
270
+ const root = input.root ?? process.cwd();
271
+ const paths = getIssuePaths(root, input.issueId);
272
+
273
+ if (!existsSync(paths.statePath)) {
274
+ throw new Error(`Issue state not found: ${input.issueId}`);
275
+ }
276
+
277
+ const raw = JSON.parse(readFileSync(paths.statePath, "utf8")) as RawIssueState;
278
+ return migrateIssueState(raw);
279
+ }
280
+
281
+ export function transitionIssuePhase(input: TransitionInput): IssueState {
282
+ const nextPhase = assertValidPhase(input.nextPhase);
283
+ const root = input.root ?? process.cwd();
284
+ const paths = getIssuePaths(root, input.issueId);
285
+ const state = readIssueState({ root, issueId: input.issueId });
286
+ const allowedNextPhase = getNextPhase(state.currentPhase);
287
+
288
+ // Allow skipping cleanup phase from self-review directly to ship
289
+ const isSkipCleanup =
290
+ input.skipCleanup && state.currentPhase === "self-review" && nextPhase === "ship";
291
+
292
+ // Allow skipping compressed phases according to rigorLevel (e.g. standard mode
293
+ // collapses local-verify + ac-check, allowing implement -> self-review).
294
+ const isSkipCompressed = isCompressedSkip(state.currentPhase, nextPhase, state.rigorLevel);
295
+
296
+ if (nextPhase !== allowedNextPhase && !isSkipCleanup && !isSkipCompressed) {
297
+ throw new Error(
298
+ `Invalid phase transition: ${state.currentPhase} -> ${nextPhase}; allowed next phase: ${
299
+ allowedNextPhase ?? "<none>"
300
+ }`,
301
+ );
302
+ }
303
+
304
+ assertPhaseGate({
305
+ issueId: input.issueId,
306
+ fromPhase: isSkipCleanup ? "cleanup" : state.currentPhase,
307
+ nextPhase,
308
+ issueDir: paths.issueDir,
309
+ });
310
+
311
+ const now = new Date().toISOString();
312
+ const sessionId = resolveSessionId();
313
+ const updated: IssueState = touchIssueOwnership({
314
+ state: {
315
+ ...state,
316
+ currentPhase: nextPhase,
317
+ updatedAt: now,
318
+ phaseHistory: [
319
+ ...state.phaseHistory,
320
+ { phase: nextPhase, enteredAt: now, fromPhase: state.currentPhase },
321
+ ],
322
+ },
323
+ sessionId,
324
+ });
325
+
326
+ writeJson(paths.statePath, updated);
327
+ const graph = JSON.parse(readFileSync(paths.graphPath, "utf8"));
328
+ writeJson(paths.graphPath, {
329
+ ...graph,
330
+ currentPhase: nextPhase,
331
+ transitions: [
332
+ ...(Array.isArray(graph.transitions) ? graph.transitions : []),
333
+ { fromPhase: state.currentPhase, toPhase: nextPhase, timestamp: now },
334
+ ],
335
+ });
336
+ const ownershipEvent = buildOwnershipChangedEvent({
337
+ issueId: input.issueId,
338
+ timestamp: now,
339
+ previousState: state,
340
+ nextState: updated,
341
+ sessionId,
342
+ });
343
+ if (ownershipEvent) {
344
+ appendIssueEvent({ issueDir: paths.issueDir, event: ownershipEvent });
345
+ }
346
+ appendIssueEvent({
347
+ issueDir: paths.issueDir,
348
+ event: {
349
+ schemaVersion: 1,
350
+ type: "phase.transitioned",
351
+ issueId: input.issueId,
352
+ timestamp: now,
353
+ sessionId,
354
+ payload: { fromPhase: state.currentPhase, toPhase: nextPhase },
355
+ },
356
+ });
357
+
358
+ getWorkflowEventEmitter().emit({
359
+ type: "issue_transitioned",
360
+ issueId: input.issueId,
361
+ fromPhase: state.currentPhase,
362
+ toPhase: nextPhase,
363
+ timestamp: now,
364
+ });
365
+
366
+ fireAndForgetSync(root, input.issueId, "transitioned", { fromPhase: state.currentPhase, toPhase: nextPhase });
367
+
368
+ return updated;
369
+ }
370
+
371
+ export function appendIssueEvent(input: { issueDir: string; event: StateEvent }) {
372
+ appendFileSync(join(input.issueDir, "events.jsonl"), `${JSON.stringify(input.event)}\n`);
373
+ }
374
+
375
+ export function setIssueArchived(input: IssueInput & { archived: boolean }): IssueState {
376
+ const root = input.root ?? process.cwd();
377
+ const paths = getIssuePaths(root, input.issueId);
378
+ const state = readIssueState({ root, issueId: input.issueId });
379
+
380
+ const now = new Date().toISOString();
381
+ const updated: IssueState = {
382
+ ...state,
383
+ archived: input.archived,
384
+ archivedAt: input.archived ? now : null,
385
+ updatedAt: now,
386
+ };
387
+ writeJson(paths.statePath, updated);
388
+
389
+ fireAndForgetSync(root, input.issueId, "archived", { archived: input.archived });
390
+
391
+ return updated;
392
+ }
393
+
394
+ export function getNextPhase(phase: GxpmPhase) {
395
+ const index = GXPM_PHASES.indexOf(phase);
396
+ return GXPM_PHASES[index + 1] ?? null;
397
+ }
398
+
399
+ /** Phases that may be compressed (skipped) under each rigor level. */
400
+ export const PHASE_SKIP_MAP: Record<RigorLevel, ReadonlySet<GxpmPhase>> = {
401
+ lite: new Set(["dispatch", "local-verify", "ac-check", "cleanup", "pr-check", "verify", "qa"]),
402
+ standard: new Set(["local-verify", "ac-check", "cleanup", "pr-check", "verify"]),
403
+ full: new Set([]),
404
+ };
405
+
406
+ /**
407
+ * Determines whether a transition from `fromPhase` to `toPhase` is allowed
408
+ * under the given `rigorLevel` by skipping intermediate compressed phases.
409
+ *
410
+ * Example: in "standard" mode, implement -> self-review is allowed because
411
+ * local-verify and ac-check are in the skip set.
412
+ */
413
+ export function isCompressedSkip(
414
+ fromPhase: GxpmPhase,
415
+ toPhase: GxpmPhase,
416
+ rigorLevel?: RigorLevel,
417
+ ): boolean {
418
+ if (!rigorLevel || rigorLevel === "full") return false;
419
+ const skipSet = PHASE_SKIP_MAP[rigorLevel];
420
+ const fromIndex = GXPM_PHASES.indexOf(fromPhase);
421
+ const toIndex = GXPM_PHASES.indexOf(toPhase);
422
+ if (toIndex <= fromIndex) return false;
423
+ for (let i = fromIndex + 1; i < toIndex; i++) {
424
+ if (!skipSet.has(GXPM_PHASES[i])) return false;
425
+ }
426
+ return true;
427
+ }
428
+
429
+ /** Returns the phases visible for a given rigor level (used by CLI/UI). */
430
+ export function getVisiblePhases(rigorLevel?: RigorLevel): readonly GxpmPhase[] {
431
+ if (!rigorLevel || rigorLevel === "full") return GXPM_PHASES;
432
+ const skipSet = PHASE_SKIP_MAP[rigorLevel];
433
+ return GXPM_PHASES.filter((p) => !skipSet.has(p));
434
+ }
435
+
436
+ export function isGxpmPhase(value: string): value is GxpmPhase {
437
+ return GXPM_PHASES.includes(value as GxpmPhase);
438
+ }
439
+
440
+ export function isIssueType(value: string): value is IssueType {
441
+ return ISSUE_TYPES.includes(value as IssueType);
442
+ }
443
+
444
+ export function normalizeIssueType(value: unknown): IssueType {
445
+ return typeof value === "string" && isIssueType(value) ? value : "feature";
446
+ }
447
+
448
+ export function isRigorLevel(value: string): value is RigorLevel {
449
+ return RIGOR_LEVELS.includes(value as RigorLevel);
450
+ }
451
+
452
+ export function normalizeRigorLevel(value: unknown): RigorLevel | undefined {
453
+ return typeof value === "string" && isRigorLevel(value) ? value : undefined;
454
+ }
455
+
456
+ function migrateIssueState(raw: RawIssueState): IssueState {
457
+ if (raw.schemaVersion !== CURRENT_SCHEMA_VERSION) {
458
+ throw new Error(`Unsupported issue state schemaVersion: ${String(raw.schemaVersion)}`);
459
+ }
460
+
461
+ return {
462
+ schemaVersion: CURRENT_SCHEMA_VERSION,
463
+ issueId: String(raw.issueId),
464
+ issueType: normalizeIssueType(raw.issueType),
465
+ rigorLevel: normalizeRigorLevel(raw.rigorLevel),
466
+ currentPhase: assertValidPhase(String(raw.currentPhase)),
467
+ createdAt: String(raw.createdAt),
468
+ updatedAt: String(raw.updatedAt),
469
+ stateRoot: String(raw.stateRoot),
470
+ artifactRoot: String(raw.artifactRoot),
471
+ creator: normalizeCreator(raw.creator),
472
+ claim: normalizeClaim(raw.claim),
473
+ relations: normalizeRelations(raw.relations),
474
+ archived: typeof raw.archived === "boolean" ? raw.archived : undefined,
475
+ archivedAt:
476
+ typeof raw.archivedAt === "string" || raw.archivedAt === null ? raw.archivedAt : undefined,
477
+ ownership: normalizeOwnership(raw.ownership),
478
+ phaseHistory: Array.isArray(raw.phaseHistory)
479
+ ? raw.phaseHistory.map((entry) => {
480
+ const record = entry as Record<string, unknown>;
481
+ return {
482
+ phase: assertValidPhase(String(record.phase)),
483
+ enteredAt: String(record.enteredAt),
484
+ fromPhase:
485
+ record.fromPhase === null ? null : assertValidPhase(String(record.fromPhase)),
486
+ };
487
+ })
488
+ : [],
489
+ };
490
+ }
491
+
492
+ function assertValidIssueId(issueId: string) {
493
+ if (!ISSUE_ID_PATTERN.test(issueId)) {
494
+ throw new Error(`Invalid issue id: ${issueId}`);
495
+ }
496
+ }
497
+
498
+ export function assertValidPhase(value: string): GxpmPhase {
499
+ if (!isGxpmPhase(value)) {
500
+ throw new Error(`Invalid phase: ${value}`);
501
+ }
502
+ return value;
503
+ }
504
+
505
+ function writeJson(path: string, value: unknown) {
506
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
507
+ }
508
+
509
+ function fireAndForgetSync(
510
+ root: string,
511
+ issueId: string,
512
+ action: string,
513
+ meta: Record<string, unknown>,
514
+ ) {
515
+ import("./issue-sync")
516
+ .then(({ maybeSyncIssue }) => maybeSyncIssue({ root, issueId, action, meta }))
517
+ .catch(() => {
518
+ // Silently fail — local state is truth
519
+ });
520
+ }
521
+
522
+ export function touchIssueOwnership(input: { state: IssueState; sessionId: string }): IssueState {
523
+ const ownership = normalizeOwnership(input.state.ownership);
524
+ const touchedAt = input.state.updatedAt;
525
+ if (!ownership) {
526
+ return {
527
+ ...input.state,
528
+ ownership: {
529
+ currentSession: input.sessionId,
530
+ lastTouchedAt: touchedAt,
531
+ history: [{ sessionId: input.sessionId, firstTouch: touchedAt, lastTouch: touchedAt }],
532
+ },
533
+ };
534
+ }
535
+
536
+ const existingEntry = ownership.history.find((entry) => entry.sessionId === input.sessionId);
537
+ const nextHistory = existingEntry
538
+ ? ownership.history.map((entry) =>
539
+ entry.sessionId === input.sessionId ? { ...entry, lastTouch: touchedAt } : entry,
540
+ )
541
+ : [...ownership.history, { sessionId: input.sessionId, firstTouch: touchedAt, lastTouch: touchedAt }];
542
+
543
+ return {
544
+ ...input.state,
545
+ ownership: {
546
+ currentSession: input.sessionId,
547
+ lastTouchedAt: touchedAt,
548
+ history: nextHistory,
549
+ },
550
+ };
551
+ }
552
+
553
+ export function buildOwnershipChangedEvent(input: {
554
+ issueId: string;
555
+ timestamp: string;
556
+ previousState: IssueState;
557
+ nextState: IssueState;
558
+ sessionId: string;
559
+ }): StateEvent | null {
560
+ const before = normalizeOwnership(input.previousState.ownership);
561
+ const after = normalizeOwnership(input.nextState.ownership);
562
+ if (!before || !after || before.currentSession === after.currentSession) {
563
+ return null;
564
+ }
565
+ return {
566
+ schemaVersion: 1,
567
+ type: "ownership.changed",
568
+ issueId: input.issueId,
569
+ timestamp: input.timestamp,
570
+ sessionId: input.sessionId,
571
+ payload: {
572
+ fromSession: before.currentSession,
573
+ toSession: after.currentSession,
574
+ changedAt: input.timestamp,
575
+ },
576
+ };
577
+ }
578
+
579
+ function normalizeOwnership(value: unknown): IssueOwnership | undefined {
580
+ if (!value || typeof value !== "object") {
581
+ return undefined;
582
+ }
583
+ const record = value as Record<string, unknown>;
584
+ const currentSession = typeof record.currentSession === "string" ? record.currentSession : "";
585
+ if (!currentSession.trim()) {
586
+ return undefined;
587
+ }
588
+
589
+ const history = normalizeOwnershipHistory(record.history);
590
+ const lastTouchedAt =
591
+ typeof record.lastTouchedAt === "string"
592
+ ? record.lastTouchedAt
593
+ : history.find((entry) => entry.sessionId === currentSession)?.lastTouch;
594
+
595
+ const ensuredHistory = ensureCurrentSessionHistory(history, currentSession, lastTouchedAt);
596
+ const fallbackLastTouch =
597
+ ensuredHistory.find((entry) => entry.sessionId === currentSession)?.lastTouch ??
598
+ new Date(0).toISOString();
599
+
600
+ return {
601
+ currentSession,
602
+ lastTouchedAt: lastTouchedAt ?? fallbackLastTouch,
603
+ history: ensuredHistory,
604
+ };
605
+ }
606
+
607
+ function normalizeCreator(value: unknown): IssueCreator | undefined {
608
+ if (!value || typeof value !== "object") {
609
+ return undefined;
610
+ }
611
+ const record = value as Record<string, unknown>;
612
+ const host = typeof record.host === "string" && record.host.trim() ? record.host : "";
613
+ const sessionId =
614
+ typeof record.sessionId === "string" && record.sessionId.trim() ? record.sessionId : "";
615
+ const actor = typeof record.actor === "string" && record.actor.trim() ? record.actor : "";
616
+ const createdAt =
617
+ typeof record.createdAt === "string" && record.createdAt.trim() ? record.createdAt : "";
618
+ if (!host || !sessionId || !actor || !createdAt) {
619
+ return undefined;
620
+ }
621
+ return { host, sessionId, actor, createdAt };
622
+ }
623
+
624
+ function normalizeClaim(value: unknown): IssueClaim | undefined {
625
+ if (!value || typeof value !== "object") {
626
+ return undefined;
627
+ }
628
+ const record = value as Record<string, unknown>;
629
+ if (record.status !== "claimed" && record.status !== "released" && record.status !== "stale") {
630
+ return undefined;
631
+ }
632
+ const actor = typeof record.actor === "string" && record.actor.trim() ? record.actor : "";
633
+ const claimedBySession =
634
+ typeof record.claimedBySession === "string" && record.claimedBySession.trim()
635
+ ? record.claimedBySession
636
+ : "";
637
+ const claimedAt =
638
+ typeof record.claimedAt === "string" && record.claimedAt.trim() ? record.claimedAt : "";
639
+ if (!actor || !claimedBySession || !claimedAt) {
640
+ return undefined;
641
+ }
642
+ const runId = typeof record.runId === "string" && record.runId.trim() ? record.runId : undefined;
643
+ const base = { actor, claimedBySession, claimedAt, runId };
644
+ if (record.status === "claimed") {
645
+ return { status: "claimed", ...base };
646
+ }
647
+ if (record.status === "released") {
648
+ const releasedAt =
649
+ typeof record.releasedAt === "string" && record.releasedAt.trim() ? record.releasedAt : "";
650
+ const releasedBySession =
651
+ typeof record.releasedBySession === "string" && record.releasedBySession.trim()
652
+ ? record.releasedBySession
653
+ : "";
654
+ const releaseReason =
655
+ typeof record.releaseReason === "string" && record.releaseReason.trim()
656
+ ? record.releaseReason
657
+ : "";
658
+ if (!releasedAt || !releasedBySession || !releaseReason) {
659
+ return undefined;
660
+ }
661
+ return { status: "released", ...base, releasedAt, releasedBySession, releaseReason };
662
+ }
663
+
664
+ const staleAt = typeof record.staleAt === "string" && record.staleAt.trim() ? record.staleAt : "";
665
+ const staleReason =
666
+ typeof record.staleReason === "string" && record.staleReason.trim() ? record.staleReason : "";
667
+ if (!staleAt || !staleReason) {
668
+ return undefined;
669
+ }
670
+ return { status: "stale", ...base, staleAt, staleReason };
671
+ }
672
+
673
+ function normalizeRelations(value: unknown): IssueRelation[] | undefined {
674
+ if (!Array.isArray(value)) {
675
+ return undefined;
676
+ }
677
+ const result: IssueRelation[] = [];
678
+ for (const item of value) {
679
+ if (!item || typeof item !== "object") continue;
680
+ const record = item as Record<string, unknown>;
681
+ const relation =
682
+ record.relation === "parent" || record.relation === "child" || record.relation === "related"
683
+ ? record.relation
684
+ : undefined;
685
+ const issueId = typeof record.issueId === "string" && record.issueId.trim() ? record.issueId : "";
686
+ const createdAt =
687
+ typeof record.createdAt === "string" && record.createdAt.trim() ? record.createdAt : "";
688
+ if (relation && issueId && createdAt) {
689
+ result.push({ relation, issueId, createdAt });
690
+ }
691
+ }
692
+ return result.length > 0 ? result : undefined;
693
+ }
694
+
695
+ function normalizeOwnershipHistory(value: unknown): IssueOwnershipHistoryEntry[] {
696
+ if (!Array.isArray(value)) {
697
+ return [];
698
+ }
699
+ if (value.every((item) => typeof item === "string" && item.trim().length > 0)) {
700
+ return value.map((sessionId) => ({
701
+ sessionId,
702
+ firstTouch: new Date(0).toISOString(),
703
+ lastTouch: new Date(0).toISOString(),
704
+ }));
705
+ }
706
+ return value
707
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
708
+ .map((item) => {
709
+ const sessionId = typeof item.sessionId === "string" ? item.sessionId : "";
710
+ const firstTouch = typeof item.firstTouch === "string" ? item.firstTouch : "";
711
+ const lastTouch = typeof item.lastTouch === "string" ? item.lastTouch : firstTouch;
712
+ return { sessionId, firstTouch, lastTouch };
713
+ })
714
+ .filter((entry) => entry.sessionId.trim().length > 0 && entry.firstTouch && entry.lastTouch);
715
+ }
716
+
717
+ function ensureCurrentSessionHistory(
718
+ history: IssueOwnershipHistoryEntry[],
719
+ currentSession: string,
720
+ lastTouchedAt?: string,
721
+ ): IssueOwnershipHistoryEntry[] {
722
+ const existing = history.find((entry) => entry.sessionId === currentSession);
723
+ if (existing) {
724
+ if (lastTouchedAt && existing.lastTouch !== lastTouchedAt) {
725
+ return history.map((entry) =>
726
+ entry.sessionId === currentSession ? { ...entry, lastTouch: lastTouchedAt } : entry,
727
+ );
728
+ }
729
+ return history;
730
+ }
731
+ const touch = lastTouchedAt ?? new Date(0).toISOString();
732
+ return [...history, { sessionId: currentSession, firstTouch: touch, lastTouch: touch }];
733
+ }
734
+
735
+ function assertWorktreeGate(input: {
736
+ issueId: string;
737
+ fromPhase: GxpmPhase;
738
+ nextPhase: GxpmPhase;
739
+ issueDir: string;
740
+ }) {
741
+ if (input.fromPhase !== "dispatch" || input.nextPhase !== "specify") {
742
+ return;
743
+ }
744
+ const branch = getCurrentGitBranch();
745
+ const baseBranch = getResolvedConfigValue({ key: "worktree.baseBranch" }).value as string;
746
+ if (!branch || branch === baseBranch) {
747
+ return;
748
+ }
749
+ const canonicalRoot = getCanonicalMainRoot();
750
+ const currentRoot = getCurrentGitRoot();
751
+ if (!canonicalRoot || !currentRoot || normalizePath(currentRoot) !== normalizePath(canonicalRoot)) {
752
+ return;
753
+ }
754
+
755
+ const now = new Date().toISOString();
756
+ appendIssueEvent({
757
+ issueDir: input.issueDir,
758
+ event: {
759
+ schemaVersion: 1,
760
+ type: "gate.blocked",
761
+ issueId: input.issueId,
762
+ timestamp: now,
763
+ sessionId: resolveSessionId(),
764
+ payload: {
765
+ fromPhase: input.fromPhase,
766
+ toPhase: input.nextPhase,
767
+ reason: `dispatch-to-specify blocked: canonical main checkout must stay on ${baseBranch}; create a git worktree for feature branches`,
768
+ },
769
+ },
770
+ });
771
+ throw new Error(
772
+ `Transition blocked: dispatch -> specify requires a dedicated git worktree when on a feature branch. ` +
773
+ `Current directory is the canonical main checkout on branch '${branch}'. ` +
774
+ `Run: gxpm workspace ensure ${input.issueId}`,
775
+ );
776
+ }
777
+
778
+ function assertArtifactGate(input: {
779
+ issueId: string;
780
+ fromPhase: GxpmPhase;
781
+ nextPhase: GxpmPhase;
782
+ issueDir: string;
783
+ }) {
784
+ const requiredArtifact = getRequiredArtifactForTransition(input.fromPhase, input.nextPhase);
785
+ if (!requiredArtifact) {
786
+ return;
787
+ }
788
+
789
+ // Derive root from issueDir (<root>/.gxpm/issues/<id>) so readIssueState
790
+ // uses the same temp dir in tests rather than process.cwd().
791
+ const derivedRoot = resolve(input.issueDir, "..", "..", "..");
792
+
793
+ const requiredArtifactPath = join(input.issueDir, "artifacts", `${requiredArtifact}.json`);
794
+ if (existsSync(requiredArtifactPath)) {
795
+ // Task 4: specify->implement gate — verify confirmedAt is set.
796
+ if (requiredArtifact === "behavior-spec" && input.nextPhase === "implement") {
797
+ const state = readIssueState({ root: derivedRoot, issueId: input.issueId });
798
+ const legacyEntry = state.phaseHistory?.find((h) => h.phase === "implement");
799
+ const isLegacy =
800
+ legacyEntry !== undefined && legacyEntry.enteredAt < SPECIFY_PHASE_CUTOFF;
801
+ if (isLegacy) {
802
+ const now = new Date().toISOString();
803
+ appendIssueEvent({
804
+ issueDir: input.issueDir,
805
+ event: {
806
+ schemaVersion: 1,
807
+ type: "gate.blocked",
808
+ issueId: input.issueId,
809
+ timestamp: now,
810
+ sessionId: resolveSessionId(),
811
+ payload: {
812
+ fromPhase: input.fromPhase,
813
+ toPhase: input.nextPhase,
814
+ missingArtifact: "behavior-spec.confirmedAt",
815
+ legacyBypass: true,
816
+ },
817
+ },
818
+ });
819
+ return;
820
+ }
821
+ const raw = JSON.parse(readFileSync(requiredArtifactPath, "utf8"));
822
+ const confirmedAt = raw?.payload?.confirmedAt;
823
+ if (!confirmedAt) {
824
+ const now = new Date().toISOString();
825
+ appendIssueEvent({
826
+ issueDir: input.issueDir,
827
+ event: {
828
+ schemaVersion: 1,
829
+ type: "gate.blocked",
830
+ issueId: input.issueId,
831
+ timestamp: now,
832
+ sessionId: resolveSessionId(),
833
+ payload: {
834
+ fromPhase: input.fromPhase,
835
+ toPhase: input.nextPhase,
836
+ missingArtifact: "behavior-spec.confirmedAt",
837
+ },
838
+ },
839
+ });
840
+ throw new Error(
841
+ `behavior-spec exists but confirmedAt is null; run \`gxpm specify confirm ${input.issueId}\` to confirm`,
842
+ );
843
+ }
844
+ }
845
+
846
+ // Army mode: check review-report for blocking findings on cleanup -> ship
847
+ if (input.fromPhase === "cleanup" && input.nextPhase === "ship") {
848
+ const reviewReportPath = join(input.issueDir, "artifacts", "review-report.json");
849
+ if (existsSync(reviewReportPath)) {
850
+ const raw = JSON.parse(readFileSync(reviewReportPath, "utf8"));
851
+ const findings = raw?.payload?.findings ?? [];
852
+ const blockingCount = findings.filter(
853
+ (f: Record<string, unknown>) => f.severity === "blocking",
854
+ ).length;
855
+ if (blockingCount > 0) {
856
+ const now = new Date().toISOString();
857
+ appendIssueEvent({
858
+ issueDir: input.issueDir,
859
+ event: {
860
+ schemaVersion: 1,
861
+ type: "gate.blocked",
862
+ issueId: input.issueId,
863
+ timestamp: now,
864
+ sessionId: resolveSessionId(),
865
+ payload: {
866
+ fromPhase: input.fromPhase,
867
+ toPhase: input.nextPhase,
868
+ missingArtifact: "review-report.blocking-free",
869
+ blockingCount,
870
+ },
871
+ },
872
+ });
873
+ throw new Error(
874
+ `self-review → ship blocked: review-report contains ${blockingCount} blocking finding(s)`,
875
+ );
876
+ }
877
+ }
878
+ }
879
+
880
+ const now = new Date().toISOString();
881
+ appendIssueEvent({
882
+ issueDir: input.issueDir,
883
+ event: {
884
+ schemaVersion: 1,
885
+ type: "gate.passed",
886
+ issueId: input.issueId,
887
+ timestamp: now,
888
+ sessionId: resolveSessionId(),
889
+ payload: {
890
+ fromPhase: input.fromPhase,
891
+ toPhase: input.nextPhase,
892
+ requiredArtifact,
893
+ },
894
+ },
895
+ });
896
+ return;
897
+ }
898
+
899
+ const now = new Date().toISOString();
900
+ const legacyEntry =
901
+ requiredArtifact === "behavior-spec"
902
+ ? readIssueState({ root: derivedRoot, issueId: input.issueId }).phaseHistory?.find(
903
+ (h) => h.phase === "implement",
904
+ )
905
+ : undefined;
906
+ const isLegacyBypass =
907
+ legacyEntry !== undefined && legacyEntry.enteredAt < SPECIFY_PHASE_CUTOFF;
908
+
909
+ appendIssueEvent({
910
+ issueDir: input.issueDir,
911
+ event: {
912
+ schemaVersion: 1,
913
+ type: "gate.blocked",
914
+ issueId: input.issueId,
915
+ timestamp: now,
916
+ sessionId: resolveSessionId(),
917
+ payload: {
918
+ fromPhase: input.fromPhase,
919
+ toPhase: input.nextPhase,
920
+ missingArtifact: requiredArtifact,
921
+ ...(isLegacyBypass ? { legacyBypass: true } : {}),
922
+ },
923
+ },
924
+ });
925
+
926
+ if (isLegacyBypass) {
927
+ return; // legacy bypass: event recorded with legacyBypass: true, no throw
928
+ }
929
+
930
+ throw new Error(
931
+ `Missing required artifact: ${requiredArtifact}; run ${getGateCommand(input.issueId, requiredArtifact)}`,
932
+ );
933
+ }
934
+
935
+ function assertPhaseGate(input: {
936
+ issueId: string;
937
+ fromPhase: GxpmPhase;
938
+ nextPhase: GxpmPhase;
939
+ issueDir: string;
940
+ }) {
941
+ assertWorktreeGate(input);
942
+ assertArtifactGate(input);
943
+ }
944
+
945
+ function getCurrentGitBranch(): string | undefined {
946
+ try {
947
+ const result = Bun.spawnSync({
948
+ cmd: ["git", "rev-parse", "--abbrev-ref", "HEAD"],
949
+ cwd: process.cwd(),
950
+ stdout: "pipe",
951
+ stderr: "pipe",
952
+ });
953
+ if (result.exitCode !== 0) return undefined;
954
+ const branch = result.stdout.toString().trim();
955
+ return branch || undefined;
956
+ } catch {
957
+ return undefined;
958
+ }
959
+ }
960
+
961
+ function getCanonicalMainRoot(): string | undefined {
962
+ try {
963
+ const result = Bun.spawnSync({
964
+ cmd: ["git", "worktree", "list", "--porcelain"],
965
+ cwd: process.cwd(),
966
+ stdout: "pipe",
967
+ stderr: "pipe",
968
+ });
969
+ if (result.exitCode !== 0) return undefined;
970
+ const line = result.stdout
971
+ .toString()
972
+ .split("\n")
973
+ .find((l) => l.startsWith("worktree "));
974
+ return line?.slice("worktree ".length);
975
+ } catch {
976
+ return undefined;
977
+ }
978
+ }
979
+
980
+ function getCurrentGitRoot(): string | undefined {
981
+ try {
982
+ const result = Bun.spawnSync({
983
+ cmd: ["git", "rev-parse", "--show-toplevel"],
984
+ cwd: process.cwd(),
985
+ stdout: "pipe",
986
+ stderr: "pipe",
987
+ });
988
+ if (result.exitCode !== 0) return undefined;
989
+ return result.stdout.toString().trim() || undefined;
990
+ } catch {
991
+ return undefined;
992
+ }
993
+ }
994
+
995
+ function normalizePath(path: string): string {
996
+ const resolved = resolve(path).replace(/\/+$/, "");
997
+ try {
998
+ return realpathSync.native(resolved);
999
+ } catch {
1000
+ return resolved;
1001
+ }
1002
+ }