@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.
- package/AGENTS.md +148 -0
- package/CANON.md +53 -0
- package/CLAUDE.md +60 -0
- package/CONTEXT.md +49 -0
- package/DEBUG.md +59 -0
- package/ISSUE_CONTEXT.md +25 -0
- package/README.md +143 -0
- package/VERSION +1 -0
- package/agents/cleanup-auditor/cleanup-auditor.md +56 -0
- package/agents/grill-master.md +26 -0
- package/agents/implementer.md +32 -0
- package/agents/review-army/accessibility-reviewer.md +54 -0
- package/agents/review-army/code-quality-reviewer.md +54 -0
- package/agents/review-army/security-reviewer.md +56 -0
- package/agents/review-army/spec-compliance-reviewer.md +51 -0
- package/agents/review-army/test-reviewer.md +55 -0
- package/agents/reviewer.md +59 -0
- package/agents/ship-audit-army/docs-auditor.md +53 -0
- package/agents/ship-audit-army/performance-auditor.md +52 -0
- package/agents/ship-audit-army/security-auditor.md +52 -0
- package/agents/specifier.md +55 -0
- package/agents/triage-officer.md +27 -0
- package/bin/gxpm +17 -0
- package/bin/gxpm-browser +17 -0
- package/bin/gxpm-config +15 -0
- package/bin/gxpm-eval +13 -0
- package/bin/gxpm-global-discover +15 -0
- package/bin/gxpm-init +38 -0
- package/bin/gxpm-investigate +194 -0
- package/bin/gxpm-uninstall +15 -0
- package/bin/gxpm-update-check +165 -0
- package/commands/build.md +40 -0
- package/commands/help.md +53 -0
- package/commands/plan.md +34 -0
- package/commands/refine.md +46 -0
- package/commands/review.md +34 -0
- package/commands/ship.md +37 -0
- package/core/ac-check.ts +20 -0
- package/core/agent-runtime.ts +363 -0
- package/core/artifact-validator.ts +151 -0
- package/core/artifacts.ts +313 -0
- package/core/autopilot.ts +250 -0
- package/core/capabilities.ts +779 -0
- package/core/checkpoint.ts +370 -0
- package/core/cleanup.ts +32 -0
- package/core/command-probe.ts +82 -0
- package/core/config.ts +533 -0
- package/core/contracts/behavior-spec.schema.ts +38 -0
- package/core/contracts/converter.ts +61 -0
- package/core/contracts/host.ts +43 -0
- package/core/converters/converter.ts +93 -0
- package/core/converters/index.ts +8 -0
- package/core/converters/managed-artifact.ts +119 -0
- package/core/converters/parser.ts +159 -0
- package/core/converters/template-renderer.ts +35 -0
- package/core/converters/writer.ts +61 -0
- package/core/dag-executor.ts +426 -0
- package/core/dag-loader.ts +292 -0
- package/core/dag-schemas.ts +150 -0
- package/core/dispatch.ts +125 -0
- package/core/evidence.ts +148 -0
- package/core/gate.ts +269 -0
- package/core/hook-engine.ts +566 -0
- package/core/host-probe.ts +64 -0
- package/core/implement.ts +16 -0
- package/core/isolation-errors.ts +174 -0
- package/core/isolation-resolver.ts +921 -0
- package/core/issue-context.ts +381 -0
- package/core/issue-readiness.ts +457 -0
- package/core/issue-sync.ts +427 -0
- package/core/issues.ts +132 -0
- package/core/land.ts +108 -0
- package/core/orchestrator.ts +54 -0
- package/core/phase-artifact.ts +32 -0
- package/core/phase-gates.ts +130 -0
- package/core/phase-rewind.ts +94 -0
- package/core/plan-lint.ts +61 -0
- package/core/plan.ts +77 -0
- package/core/port-allocation.ts +50 -0
- package/core/pr-check.ts +15 -0
- package/core/preset-system/preset-resolver.ts +221 -0
- package/core/project-init-status.ts +127 -0
- package/core/qa.ts +15 -0
- package/core/resilience.ts +165 -0
- package/core/runs.ts +288 -0
- package/core/safe-path.test.ts +80 -0
- package/core/safe-path.ts +60 -0
- package/core/sdd-gate.test.ts +98 -0
- package/core/sdd-gate.ts +134 -0
- package/core/self-review.ts +62 -0
- package/core/session.ts +70 -0
- package/core/ship.ts +86 -0
- package/core/specify.ts +173 -0
- package/core/state.ts +1002 -0
- package/core/template-engine.ts +152 -0
- package/core/template-resolver.test.ts +70 -0
- package/core/template-resolver.ts +156 -0
- package/core/triage.ts +26 -0
- package/core/verify.ts +15 -0
- package/core/wiki-native.ts +2423 -0
- package/core/wiki.ts +27 -0
- package/core/workflow-event-emitter.ts +163 -0
- package/core/workflows/engine.ts +273 -0
- package/core/workflows/expressions.ts +76 -0
- package/core/workflows/index.ts +38 -0
- package/core/workflows/steps/command.ts +43 -0
- package/core/workflows/steps/gate.ts +47 -0
- package/core/workflows/steps/gxpm.ts +44 -0
- package/core/workflows/steps/linear.ts +31 -0
- package/core/workflows/steps/shell.ts +65 -0
- package/core/workflows/types.ts +62 -0
- package/core/workspace-runtime.ts +227 -0
- package/core/worktree-init-steps.ts +647 -0
- package/core/worktree-init.ts +330 -0
- package/core/worktree-owner.ts +143 -0
- package/docs/GXPM_VERIFY.md +98 -0
- package/docs/INSTALL_FOR_AGENTS.md +113 -0
- package/docs/README.md +57 -0
- package/docs/adr/adr-005-multi-platform-skill-converter.md +72 -0
- package/docs/agents/domain.md +30 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +32 -0
- package/docs/architecture/gxpm-architecture-diagram.md +265 -0
- package/docs/architecture/gxpm-current-architecture.md +175 -0
- package/docs/architecture/gxpm-current-flow.md +278 -0
- package/docs/architecture/gxpm-replacement-architecture.md +211 -0
- package/docs/architecture/gxpm-target-architecture.md +449 -0
- package/docs/architecture/gxpm-v0-contract.md +311 -0
- package/docs/architecture/layered-workflow-boundaries.md +193 -0
- package/docs/architecture/preset-system.md +126 -0
- package/docs/architecture/scaffold-northstar.md +23 -0
- package/docs/brainstorms/2026-05-14-bdd-then-tdd-design.md +320 -0
- package/docs/brainstorms/README.md +22 -0
- package/docs/brainstorms/docs-knowledge-system-requirements.md +29 -0
- package/docs/governance/beta-skill-promotion.md +39 -0
- package/docs/governance/development-contract.md +144 -0
- package/docs/governance/gherkin-style.md +90 -0
- package/docs/governance/host-adapter.md +56 -0
- package/docs/governance/skill-authoring.md +87 -0
- package/docs/governance/skill-testing.md +356 -0
- package/docs/governance/template-authoring.md +53 -0
- package/docs/migrations/v0.2.md +51 -0
- package/docs/plans/README.md +23 -0
- package/docs/plans/bdd-then-tdd-plan.md +1767 -0
- package/docs/plans/docs-knowledge-system-plan.md +31 -0
- package/docs/plans/spec-kit-sdd-adoption-plan.md +305 -0
- package/docs/research/agents-md-best-practices.md +207 -0
- package/docs/research/archon-study.md +351 -0
- package/docs/research/claude-hooks-study.md +440 -0
- package/docs/research/codex-hooks-study.md +624 -0
- package/docs/research/everything-claude-code-study.md +252 -0
- package/docs/research/from-skills-to-layered-workflow.md +322 -0
- package/docs/research/gsd-study.md +69 -0
- package/docs/research/kimi-hooks-study.md +274 -0
- package/docs/research/mattpocock-skills-comparison.md +429 -0
- package/docs/research/mattpocock-skills-study.md +275 -0
- package/docs/research/oh-my-codex-study.md +279 -0
- package/docs/research/perplexity-agent-skills-design.md +168 -0
- package/docs/research/pmc-gstack-skill-study.md +122 -0
- package/docs/research/spec-kit-study.md +224 -0
- package/docs/research/superpowers-study.md +209 -0
- package/docs/roadmap/initial-roadmap.md +53 -0
- package/docs/solutions/README.md +45 -0
- package/docs/solutions/artifact-nesting-recovery.md +58 -0
- package/docs/solutions/session-context-restore-practice.md +67 -0
- package/docs/solutions/workflow/version-drift-recovery.md +49 -0
- package/docs/solutions/worktree-gate-recovery.md +62 -0
- package/docs/specs/README.md +28 -0
- package/docs/specs/claude.md +45 -0
- package/docs/specs/codex.md +44 -0
- package/docs/specs/cursor.md +44 -0
- package/hosts/adapters/claude.ts +29 -0
- package/hosts/adapters/codex.ts +27 -0
- package/hosts/adapters/cursor.ts +27 -0
- package/hosts/adapters/kimi.ts +27 -0
- package/hosts/claude.ts +23 -0
- package/hosts/codex.ts +26 -0
- package/hosts/cursor.ts +19 -0
- package/hosts/index.ts +33 -0
- package/hosts/registry.test.ts +52 -0
- package/hosts/registry.ts +57 -0
- package/hosts/schema.ts +58 -0
- package/package.json +52 -0
- package/scripts/browser.ts +185 -0
- package/scripts/cleanup.ts +142 -0
- package/scripts/commands/artifact.ts +115 -0
- package/scripts/commands/autopilot.ts +143 -0
- package/scripts/commands/capability.ts +57 -0
- package/scripts/commands/config.ts +69 -0
- package/scripts/commands/dag.ts +126 -0
- package/scripts/commands/feedback.ts +123 -0
- package/scripts/commands/gate.ts +291 -0
- package/scripts/commands/helpers.ts +126 -0
- package/scripts/commands/hook.ts +66 -0
- package/scripts/commands/init.ts +515 -0
- package/scripts/commands/issue.ts +825 -0
- package/scripts/commands/phase.ts +61 -0
- package/scripts/commands/preset.ts +159 -0
- package/scripts/commands/runtime.ts +199 -0
- package/scripts/commands/specify.ts +71 -0
- package/scripts/commands/upgrade.ts +243 -0
- package/scripts/commands/verify.ts +183 -0
- package/scripts/commands/wiki.ts +242 -0
- package/scripts/commands/workflow.ts +131 -0
- package/scripts/dev-skill.ts +55 -0
- package/scripts/discover-skills.ts +116 -0
- package/scripts/doctor.ts +410 -0
- package/scripts/dogfood-check.ts +125 -0
- package/scripts/eval-functional.ts +218 -0
- package/scripts/eval.ts +246 -0
- package/scripts/gen-skill-docs.ts +201 -0
- package/scripts/global-discover.ts +217 -0
- package/scripts/governance-check.ts +75 -0
- package/scripts/gxpm-check.ts +12 -0
- package/scripts/gxpm.ts +216 -0
- package/scripts/host-config.ts +62 -0
- package/scripts/install-claude-hooks.ts +138 -0
- package/scripts/install-codex-hooks.ts +271 -0
- package/scripts/install-hooks.ts +128 -0
- package/scripts/install-kimi-hooks.ts +92 -0
- package/scripts/install-skill.ts +184 -0
- package/scripts/phase-artifact-commands.ts +100 -0
- package/scripts/post-land-sync.ts +46 -0
- package/scripts/scaffold-check.ts +85 -0
- package/scripts/skill-naming-check.ts +78 -0
- package/scripts/skill-structure-check.ts +157 -0
- package/scripts/skills-lock-check.ts +60 -0
- package/scripts/sync-markdown-artifacts.ts +172 -0
- package/scripts/uninstall.ts +162 -0
- package/scripts/version.ts +47 -0
- package/scripts/wait-pr-ready.ts +407 -0
- package/skills/gxpm/SKILL.md +485 -0
- package/skills/gxpm/SKILL.md.tmpl +422 -0
- package/skills/gxpm/references/CANON.md +53 -0
- package/skills/gxpm/references/key-rules.md +130 -0
- package/skills/gxpm-architecture/SKILL.md +106 -0
- package/skills/gxpm-architecture/references/DEEPENING.md +37 -0
- package/skills/gxpm-architecture/references/INTERFACE-DESIGN.md +44 -0
- package/skills/gxpm-autopilot/SKILL.md +116 -0
- package/skills/gxpm-autopilot/SKILL.md.tmpl +107 -0
- package/skills/gxpm-browser/SKILL.md +105 -0
- package/skills/gxpm-browser/SKILL.md.tmpl +41 -0
- package/skills/gxpm-browser/references/commands.md +43 -0
- package/skills/gxpm-browser/references/evidence-path.md +20 -0
- package/skills/gxpm-build/SKILL.md +78 -0
- package/skills/gxpm-cleanup/SKILL.md +76 -0
- package/skills/gxpm-debug-issue/SKILL.md +39 -0
- package/skills/gxpm-diagnose/SKILL.md +220 -0
- package/skills/gxpm-diagnose/SKILL.md.tmpl +31 -0
- package/skills/gxpm-diagnose/references/feedback-loop.md +34 -0
- package/skills/gxpm-diagnose/references/feedback-loops.md +43 -0
- package/skills/gxpm-diagnose/references/phases.md +60 -0
- package/skills/gxpm-eval/SKILL.md +78 -0
- package/skills/gxpm-explore-codebase/SKILL.md +36 -0
- package/skills/gxpm-explore-codebase/scripts/summarize-communities.ts +51 -0
- package/skills/gxpm-feedback/SKILL.md +122 -0
- package/skills/gxpm-grill/SKILL.md +159 -0
- package/skills/gxpm-grill/SKILL.md.tmpl +77 -0
- package/skills/gxpm-grill/references/documentation-templates.md +56 -0
- package/skills/gxpm-grill/references/process.md +25 -0
- package/skills/gxpm-handoff/SKILL.md +112 -0
- package/skills/gxpm-hygiene/SKILL.md +69 -0
- package/skills/gxpm-implementer/SKILL.md +142 -0
- package/skills/gxpm-implementer/SKILL.md.tmpl +141 -0
- package/skills/gxpm-linear/SKILL.md +282 -0
- package/skills/gxpm-linear/SKILL.md.tmpl +86 -0
- package/skills/gxpm-linear/references/commands.md +75 -0
- package/skills/gxpm-linear/references/workflows.md +120 -0
- package/skills/gxpm-planning/SKILL.md +134 -0
- package/skills/gxpm-prototype/SKILL.md +64 -0
- package/skills/gxpm-refactor-safely/SKILL.md +62 -0
- package/skills/gxpm-review-army/SKILL.md +117 -0
- package/skills/gxpm-review-changes/SKILL.md +36 -0
- package/skills/gxpm-setup/SKILL.md +101 -0
- package/skills/gxpm-specifier/SKILL.md +135 -0
- package/skills/gxpm-tdd/SKILL.md +187 -0
- package/skills/gxpm-tdd/references/interface-design.md +23 -0
- package/skills/gxpm-tdd/references/mocking.md +27 -0
- package/skills/gxpm-tdd/references/red-green-refactor.md +61 -0
- package/skills/gxpm-tdd/references/troubleshooting.md +28 -0
- package/skills/gxpm-tdd/references/workflow.md +50 -0
- package/skills/gxpm-tdd/testing-anti-patterns.tmpl +304 -0
- package/skills/gxpm-triage/SKILL.md +160 -0
- package/skills/gxpm-verify/SKILL.md +107 -0
- package/skills/gxpm-write-skill/SKILL.md +131 -0
- package/skills/gxpm-zoom-out/SKILL.md +69 -0
- package/skills/maintain-hygiene-skills-lock/SKILL.md +54 -0
- package/skills/maintain-hygiene-skills-lock/SKILL.md.tmpl +53 -0
- package/templates/constitution-template.md +63 -0
- package/templates/hooks/gxpm-commit-msg +16 -0
- package/templates/hooks/gxpm-post-checkout +19 -0
- package/templates/hooks/gxpm-post-commit +7 -0
- package/templates/hooks/gxpm-post-merge +29 -0
- package/templates/hooks/gxpm-pre-commit +39 -0
- package/templates/hooks/gxpm-pre-push +33 -0
- package/templates/plan-template.md.tmpl +46 -0
- package/templates/spec-template.md.tmpl +63 -0
- package/templates/specify-stub.tmpl +22 -0
- package/templates/tasks-template.md.tmpl +32 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { getIssuePaths, type GxpmPhase, type IssueState } from "./state";
|
|
6
|
+
import { type ArtifactRecord } from "./artifacts";
|
|
7
|
+
import { getConfigValue } from "./config";
|
|
8
|
+
|
|
9
|
+
export const CURRENT_SYNC_SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export interface SyncTarget {
|
|
12
|
+
provider: "linear" | "github";
|
|
13
|
+
externalId: string;
|
|
14
|
+
displayId: string;
|
|
15
|
+
url: string;
|
|
16
|
+
syncedAt?: string;
|
|
17
|
+
pendingPush?: boolean;
|
|
18
|
+
lastError?: {
|
|
19
|
+
message: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SyncState {
|
|
25
|
+
schemaVersion: number;
|
|
26
|
+
issueId: string;
|
|
27
|
+
targets: SyncTarget[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SyncProvider {
|
|
31
|
+
name: "linear" | "github";
|
|
32
|
+
createIssue(issueId: string, issueType: string, title: string): Promise<SyncTarget>;
|
|
33
|
+
updatePhase(target: SyncTarget, phase: GxpmPhase): Promise<void>;
|
|
34
|
+
updateDescription(target: SyncTarget, description: string): Promise<void>;
|
|
35
|
+
archiveIssue(target: SyncTarget, archived: boolean): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getSyncPaths(root: string, issueId: string) {
|
|
39
|
+
const paths = getIssuePaths(root, issueId);
|
|
40
|
+
return {
|
|
41
|
+
...paths,
|
|
42
|
+
syncPath: join(paths.issueDir, "sync.json"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readSyncState(input: { root?: string; issueId: string }): SyncState {
|
|
47
|
+
const root = input.root ?? process.cwd();
|
|
48
|
+
const { syncPath } = getSyncPaths(root, input.issueId);
|
|
49
|
+
if (!existsSync(syncPath)) {
|
|
50
|
+
return { schemaVersion: CURRENT_SYNC_SCHEMA_VERSION, issueId: input.issueId, targets: [] };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(syncPath, "utf8")) as SyncState;
|
|
54
|
+
if (typeof raw.schemaVersion !== "number") {
|
|
55
|
+
return { schemaVersion: CURRENT_SYNC_SCHEMA_VERSION, issueId: input.issueId, targets: [] };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
schemaVersion: raw.schemaVersion,
|
|
59
|
+
issueId: String(raw.issueId),
|
|
60
|
+
targets: Array.isArray(raw.targets) ? raw.targets.map(normalizeSyncTarget) : [],
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
return { schemaVersion: CURRENT_SYNC_SCHEMA_VERSION, issueId: input.issueId, targets: [] };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeSyncState(input: { root?: string; issueId: string; state: SyncState }) {
|
|
68
|
+
const root = input.root ?? process.cwd();
|
|
69
|
+
const { syncPath } = getSyncPaths(root, input.issueId);
|
|
70
|
+
mkdirSync(dirname(syncPath), { recursive: true });
|
|
71
|
+
writeFileSync(syncPath, `${JSON.stringify(input.state, null, 2)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeSyncTarget(value: unknown): SyncTarget {
|
|
75
|
+
const r = value as Record<string, unknown>;
|
|
76
|
+
return {
|
|
77
|
+
provider: (r.provider === "github" ? "github" : "linear") as "linear" | "github",
|
|
78
|
+
externalId: String(r.externalId ?? ""),
|
|
79
|
+
displayId: String(r.displayId ?? ""),
|
|
80
|
+
url: String(r.url ?? ""),
|
|
81
|
+
syncedAt: typeof r.syncedAt === "string" ? r.syncedAt : undefined,
|
|
82
|
+
pendingPush: typeof r.pendingPush === "boolean" ? r.pendingPush : undefined,
|
|
83
|
+
lastError:
|
|
84
|
+
r.lastError && typeof r.lastError === "object"
|
|
85
|
+
? {
|
|
86
|
+
message: String((r.lastError as Record<string, unknown>).message ?? ""),
|
|
87
|
+
timestamp: String((r.lastError as Record<string, unknown>).timestamp ?? ""),
|
|
88
|
+
}
|
|
89
|
+
: undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function logSyncError(input: {
|
|
94
|
+
root?: string;
|
|
95
|
+
issueId: string;
|
|
96
|
+
provider: string;
|
|
97
|
+
error: Error;
|
|
98
|
+
}) {
|
|
99
|
+
const state = readSyncState(input);
|
|
100
|
+
const target = state.targets.find((t) => t.provider === input.provider);
|
|
101
|
+
if (target) {
|
|
102
|
+
target.lastError = { message: input.error.message, timestamp: new Date().toISOString() };
|
|
103
|
+
target.pendingPush = true;
|
|
104
|
+
writeSyncState({ issueId: input.issueId, state });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveSyncProvider(root?: string): SyncProvider | null {
|
|
109
|
+
const provider = getSyncConfigValue(root, "sync.provider");
|
|
110
|
+
if (provider !== "linear" && provider !== "github") return null;
|
|
111
|
+
|
|
112
|
+
const autoSync = getSyncConfigValue(root, "sync.autoSync");
|
|
113
|
+
if (autoSync === false) return null;
|
|
114
|
+
|
|
115
|
+
if (provider === "linear") {
|
|
116
|
+
return createLinearProvider(root);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getRepoName(root?: string): string {
|
|
122
|
+
const cwd = root ?? process.cwd();
|
|
123
|
+
try {
|
|
124
|
+
const remoteUrl = execSync("git remote get-url origin", {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
128
|
+
}).trim();
|
|
129
|
+
const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
130
|
+
if (match) return match[2];
|
|
131
|
+
} catch {
|
|
132
|
+
// not a git repo or no remote
|
|
133
|
+
}
|
|
134
|
+
return basename(cwd);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createLinearProvider(root?: string): SyncProvider | null {
|
|
138
|
+
// Verify linear CLI is available
|
|
139
|
+
try {
|
|
140
|
+
execSync("linear --version", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], env: { ...process.env, PATH: process.env.PATH } });
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const teamKey = String(getSyncConfigValue(root, "sync.linearTeamKey") ?? "");
|
|
146
|
+
if (!teamKey) return null;
|
|
147
|
+
|
|
148
|
+
const assigneeId = String(getSyncConfigValue(root, "sync.linearAssigneeId") ?? "");
|
|
149
|
+
|
|
150
|
+
const runLinear = (args: string[]): Record<string, unknown> => {
|
|
151
|
+
const cmd = `linear ${args.map((a) => (a.includes(" ") || a.includes("'") ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ")}`;
|
|
152
|
+
const output = execSync(cmd, {
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
155
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
156
|
+
env: { ...process.env, PATH: process.env.PATH },
|
|
157
|
+
});
|
|
158
|
+
return JSON.parse(output) as Record<string, unknown>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const ensureLabel = (name: string) => {
|
|
162
|
+
try {
|
|
163
|
+
execSync(`linear label create --name "${name.replace(/"/g, '\\"')}" --color "#6B7280" --team ${teamKey}`, {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
166
|
+
env: { ...process.env, PATH: process.env.PATH },
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
// Label may already exist; ignore error
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const withTempFile = <T>(content: string, fn: (path: string) => T): T => {
|
|
174
|
+
const path = join(tmpdir(), `gxpm-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
|
|
175
|
+
writeFileSync(path, content);
|
|
176
|
+
try {
|
|
177
|
+
return fn(path);
|
|
178
|
+
} finally {
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(path);
|
|
181
|
+
} catch {
|
|
182
|
+
// ignore cleanup failure
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
name: "linear",
|
|
189
|
+
|
|
190
|
+
async createIssue(issueId: string, issueType: string, title: string): Promise<SyncTarget> {
|
|
191
|
+
const stateType = GXPM_PHASE_TO_LINEAR_STATE["triage"].type;
|
|
192
|
+
const repoName = getRepoName(root);
|
|
193
|
+
const repoLabel = `repo:${repoName}`;
|
|
194
|
+
|
|
195
|
+
ensureLabel(repoLabel);
|
|
196
|
+
|
|
197
|
+
const description = buildLinearDescription({
|
|
198
|
+
issueId,
|
|
199
|
+
issueType,
|
|
200
|
+
phase: "triage",
|
|
201
|
+
artifacts: [],
|
|
202
|
+
repoName,
|
|
203
|
+
root,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = withTempFile(description, (descPath) => {
|
|
207
|
+
const args = [
|
|
208
|
+
"issue", "create",
|
|
209
|
+
"--json",
|
|
210
|
+
"--title", title || `[${issueId}] ${issueType} issue`,
|
|
211
|
+
"--team", teamKey,
|
|
212
|
+
"--state", stateType,
|
|
213
|
+
"--label", repoLabel,
|
|
214
|
+
"--description-file", descPath,
|
|
215
|
+
];
|
|
216
|
+
if (assigneeId) {
|
|
217
|
+
args.push("--assignee", assigneeId);
|
|
218
|
+
}
|
|
219
|
+
return runLinear(args);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const success = result.success !== false;
|
|
223
|
+
if (!success) {
|
|
224
|
+
const error = (result.error as Record<string, unknown>)?.message ?? "Linear CLI issue create failed";
|
|
225
|
+
throw new Error(`Linear CLI error: ${error}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
provider: "linear",
|
|
230
|
+
externalId: String(result.id ?? ""),
|
|
231
|
+
displayId: String(result.identifier ?? ""),
|
|
232
|
+
url: String(result.url ?? ""),
|
|
233
|
+
syncedAt: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async updatePhase(target: SyncTarget, phase: GxpmPhase): Promise<void> {
|
|
238
|
+
const stateType = GXPM_PHASE_TO_LINEAR_STATE[phase]?.type;
|
|
239
|
+
if (stateType) {
|
|
240
|
+
runLinear(["issue", "move", target.displayId, stateType, "--json"]);
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async updateDescription(target: SyncTarget, description: string): Promise<void> {
|
|
245
|
+
withTempFile(description, (descPath) => {
|
|
246
|
+
runLinear(["issue", "update", target.displayId, "--description-file", descPath, "--json"]);
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async archiveIssue(target: SyncTarget, archived: boolean): Promise<void> {
|
|
251
|
+
const stateType = archived ? "canceled" : "completed";
|
|
252
|
+
runLinear(["issue", "move", target.displayId, stateType, "--json"]);
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function maybeSyncIssue(input: {
|
|
258
|
+
root?: string;
|
|
259
|
+
issueId: string;
|
|
260
|
+
action: "created" | "transitioned" | "artifact-written" | "archived";
|
|
261
|
+
meta?: Record<string, unknown>;
|
|
262
|
+
}): Promise<void> {
|
|
263
|
+
const root = input.root ?? process.cwd();
|
|
264
|
+
const provider = resolveSyncProvider(root);
|
|
265
|
+
if (!provider) return;
|
|
266
|
+
|
|
267
|
+
const syncState = readSyncState({ root, issueId: input.issueId });
|
|
268
|
+
let target = syncState.targets.find((t) => t.provider === provider.name);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
if (input.action === "created") {
|
|
272
|
+
if (target) return; // Already linked
|
|
273
|
+
const issueState = JSON.parse(
|
|
274
|
+
readFileSync(getIssuePaths(root, input.issueId).statePath, "utf8"),
|
|
275
|
+
) as IssueState;
|
|
276
|
+
target = await provider.createIssue(
|
|
277
|
+
input.issueId,
|
|
278
|
+
issueState.issueType ?? "feature",
|
|
279
|
+
`[${input.issueId}] ${issueState.issueType ?? "feature"} issue`,
|
|
280
|
+
);
|
|
281
|
+
syncState.targets.push(target);
|
|
282
|
+
writeSyncState({ root, issueId: input.issueId, state: syncState });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!target) return; // No binding yet; skip
|
|
287
|
+
|
|
288
|
+
if (input.action === "transitioned") {
|
|
289
|
+
const phase = String(input.meta?.toPhase ?? "") as GxpmPhase;
|
|
290
|
+
await provider.updatePhase(target, phase);
|
|
291
|
+
target.syncedAt = new Date().toISOString();
|
|
292
|
+
target.pendingPush = false;
|
|
293
|
+
target.lastError = undefined;
|
|
294
|
+
writeSyncState({ root, issueId: input.issueId, state: syncState });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (input.action === "artifact-written") {
|
|
299
|
+
const syncArtifacts = getSyncConfigValue(root, "sync.syncArtifacts");
|
|
300
|
+
if (syncArtifacts === false) return;
|
|
301
|
+
|
|
302
|
+
const artifactIndexPath = getIssuePaths(root, input.issueId).artifactIndexPath;
|
|
303
|
+
let artifacts: ArtifactRecord[] = [];
|
|
304
|
+
if (existsSync(artifactIndexPath)) {
|
|
305
|
+
const index = JSON.parse(readFileSync(artifactIndexPath, "utf8")) as { artifacts?: ArtifactRecord[] };
|
|
306
|
+
artifacts = index.artifacts ?? [];
|
|
307
|
+
}
|
|
308
|
+
const state = JSON.parse(
|
|
309
|
+
readFileSync(getIssuePaths(root, input.issueId).statePath, "utf8"),
|
|
310
|
+
) as IssueState;
|
|
311
|
+
const description = buildLinearDescription({
|
|
312
|
+
issueId: input.issueId,
|
|
313
|
+
issueType: state.issueType ?? "feature",
|
|
314
|
+
phase: state.currentPhase,
|
|
315
|
+
artifacts,
|
|
316
|
+
repoName: getRepoName(root),
|
|
317
|
+
root,
|
|
318
|
+
});
|
|
319
|
+
await provider.updateDescription(target, description);
|
|
320
|
+
target.syncedAt = new Date().toISOString();
|
|
321
|
+
target.pendingPush = false;
|
|
322
|
+
target.lastError = undefined;
|
|
323
|
+
writeSyncState({ root, issueId: input.issueId, state: syncState });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (input.action === "archived") {
|
|
328
|
+
const archived = Boolean(input.meta?.archived);
|
|
329
|
+
await provider.archiveIssue(target, archived);
|
|
330
|
+
target.syncedAt = new Date().toISOString();
|
|
331
|
+
target.pendingPush = false;
|
|
332
|
+
target.lastError = undefined;
|
|
333
|
+
writeSyncState({ root, issueId: input.issueId, state: syncState });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logSyncError({ root, issueId: input.issueId, provider: provider.name, error: error as Error });
|
|
338
|
+
// Silently fail — local state is truth
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getSyncConfigValue(root: string | undefined, key: string): unknown {
|
|
343
|
+
if (!isTestSyncIsolated()) {
|
|
344
|
+
return getConfigValue({ root, key }).value;
|
|
345
|
+
}
|
|
346
|
+
const syncKey = key.replace(/^sync\./, "");
|
|
347
|
+
return readRepoSyncConfig(root ?? process.cwd())[syncKey];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isTestSyncIsolated(): boolean {
|
|
351
|
+
return process.env.NODE_ENV === "test" && process.env.GXPM_TEST_ALLOW_LIVE_SYNC !== "1";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function readRepoSyncConfig(root: string): Record<string, unknown> {
|
|
355
|
+
const configPath = join(root, ".gxpm", "config.json");
|
|
356
|
+
if (!existsSync(configPath)) return {};
|
|
357
|
+
try {
|
|
358
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as { sync?: unknown };
|
|
359
|
+
if (!config.sync || typeof config.sync !== "object" || Array.isArray(config.sync)) {
|
|
360
|
+
return {};
|
|
361
|
+
}
|
|
362
|
+
return config.sync as Record<string, unknown>;
|
|
363
|
+
} catch {
|
|
364
|
+
return {};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const GXPM_PHASE_TO_LINEAR_STATE: Record<GxpmPhase, { type: string }> = {
|
|
369
|
+
triage: { type: "triage" },
|
|
370
|
+
plan: { type: "backlog" },
|
|
371
|
+
dispatch: { type: "unstarted" },
|
|
372
|
+
implement: { type: "started" },
|
|
373
|
+
"local-verify": { type: "started" },
|
|
374
|
+
"ac-check": { type: "started" },
|
|
375
|
+
"self-review": { type: "started" },
|
|
376
|
+
ship: { type: "started" },
|
|
377
|
+
"pr-check": { type: "started" },
|
|
378
|
+
verify: { type: "started" },
|
|
379
|
+
qa: { type: "started" },
|
|
380
|
+
land: { type: "completed" },
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const GXPM_PHASE_TO_LINEAR_LABEL: Record<GxpmPhase, string> = {
|
|
384
|
+
triage: "gxpm:phase/triage",
|
|
385
|
+
plan: "gxpm:phase/plan",
|
|
386
|
+
dispatch: "gxpm:phase/dispatch",
|
|
387
|
+
implement: "gxpm:phase/implement",
|
|
388
|
+
"local-verify": "gxpm:phase/local-verify",
|
|
389
|
+
"ac-check": "gxpm:phase/ac-check",
|
|
390
|
+
"self-review": "gxpm:phase/self-review",
|
|
391
|
+
ship: "gxpm:phase/ship",
|
|
392
|
+
"pr-check": "gxpm:phase/pr-check",
|
|
393
|
+
verify: "gxpm:phase/verify",
|
|
394
|
+
qa: "gxpm:phase/qa",
|
|
395
|
+
land: "gxpm:phase/land",
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function buildLinearDescription(input: {
|
|
399
|
+
issueId: string;
|
|
400
|
+
issueType: string;
|
|
401
|
+
phase: GxpmPhase;
|
|
402
|
+
artifacts: ArtifactRecord[];
|
|
403
|
+
repoName?: string;
|
|
404
|
+
root?: string;
|
|
405
|
+
}): string {
|
|
406
|
+
const artifactList = input.artifacts
|
|
407
|
+
.map((a) => `- \`${a.type}\` — written at ${a.writtenAt}`)
|
|
408
|
+
.join("\n") || "- none yet";
|
|
409
|
+
|
|
410
|
+
const repoLine = input.repoName ? `**Repository:** \`${input.repoName}\`\n` : "";
|
|
411
|
+
const pathLine = input.root ? `**Local Path:** \`${input.root}\`\n` : "";
|
|
412
|
+
|
|
413
|
+
return `## 🎯 gxpm Tracking Issue
|
|
414
|
+
|
|
415
|
+
**Local ID:** ${input.issueId}
|
|
416
|
+
${repoLine}${pathLine}**Type:** ${input.issueType}
|
|
417
|
+
**Phase:** ${input.phase}
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
### 📋 Artifacts
|
|
422
|
+
${artifactList}
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
> This issue is managed by gxpm. Local state at \`.gxpm/issues/${input.issueId}/\` is the source of truth.`;
|
|
427
|
+
}
|
package/core/issues.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { normalizeIssueType, type GxpmPhase, type IssueState, type IssueType } from "./state";
|
|
4
|
+
|
|
5
|
+
export interface IssueListEntry {
|
|
6
|
+
issueId: string;
|
|
7
|
+
issueType: IssueType;
|
|
8
|
+
currentPhase: GxpmPhase;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
stateRoot: string;
|
|
12
|
+
archived: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ListIssuesInput {
|
|
16
|
+
root?: string;
|
|
17
|
+
/** Show all issues (including land + archived). Default false. */
|
|
18
|
+
includeAll?: boolean;
|
|
19
|
+
/** Show only archived issues. Default false. Ignored if includeAll. */
|
|
20
|
+
archivedOnly?: boolean;
|
|
21
|
+
/** Filter by issue type. Defaults to feature for the focused active list. */
|
|
22
|
+
types?: IssueType[];
|
|
23
|
+
/** Limit result count after sorting. Defaults to 5 for the focused active list. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function listIssues(input: ListIssuesInput = {}): IssueListEntry[] {
|
|
28
|
+
const root = input.root ?? process.cwd();
|
|
29
|
+
const issuesDir = join(root, ".gxpm", "issues");
|
|
30
|
+
|
|
31
|
+
if (!existsSync(issuesDir)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entries: IssueListEntry[] = [];
|
|
36
|
+
|
|
37
|
+
for (const name of readdirSync(issuesDir)) {
|
|
38
|
+
const issueDir = join(issuesDir, name);
|
|
39
|
+
|
|
40
|
+
let isDir = false;
|
|
41
|
+
try {
|
|
42
|
+
isDir = statSync(issueDir).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!isDir) continue;
|
|
47
|
+
|
|
48
|
+
const statePath = join(issueDir, "state.json");
|
|
49
|
+
if (!existsSync(statePath)) continue;
|
|
50
|
+
|
|
51
|
+
let state: IssueState;
|
|
52
|
+
try {
|
|
53
|
+
state = JSON.parse(readFileSync(statePath, "utf8")) as IssueState;
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!state || typeof state !== "object" || !state.issueId) continue;
|
|
59
|
+
|
|
60
|
+
const archived = state.archived === true;
|
|
61
|
+
const issueType = normalizeIssueType(state.issueType);
|
|
62
|
+
|
|
63
|
+
// Filter: default hides archived AND landed; --all overrides; --archived narrows to only archived.
|
|
64
|
+
if (!input.includeAll) {
|
|
65
|
+
if (input.archivedOnly) {
|
|
66
|
+
if (!archived) continue;
|
|
67
|
+
} else {
|
|
68
|
+
if (archived) continue;
|
|
69
|
+
if (state.currentPhase === "land") continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (input.types && !input.types.includes(issueType)) continue;
|
|
73
|
+
|
|
74
|
+
entries.push({
|
|
75
|
+
issueId: state.issueId,
|
|
76
|
+
issueType,
|
|
77
|
+
currentPhase: state.currentPhase,
|
|
78
|
+
createdAt: state.createdAt,
|
|
79
|
+
updatedAt: state.updatedAt,
|
|
80
|
+
stateRoot: state.stateRoot,
|
|
81
|
+
archived,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
entries.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
|
|
86
|
+
const hasTypeFilter = (input.types?.length ?? 0) > 0;
|
|
87
|
+
const defaultLimit = input.includeAll || input.archivedOnly || hasTypeFilter ? undefined : 5;
|
|
88
|
+
const limit = input.limit ?? defaultLimit;
|
|
89
|
+
const defaultFeatureOnly = !input.includeAll && !input.archivedOnly && !hasTypeFilter;
|
|
90
|
+
const filtered = defaultFeatureOnly ? entries.filter((entry) => entry.issueType === "feature") : entries;
|
|
91
|
+
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pick next available GXPM-N id that does not already have a state.json.
|
|
96
|
+
* Resolves the F-1 collision risk where agent hard-codes an id that's already taken.
|
|
97
|
+
*/
|
|
98
|
+
export function getNextAvailableIssueId(input: { root?: string; prefix?: string } = {}): string {
|
|
99
|
+
const root = input.root ?? process.cwd();
|
|
100
|
+
const prefix = input.prefix ?? "GXPM";
|
|
101
|
+
const issuesDir = join(root, ".gxpm", "issues");
|
|
102
|
+
|
|
103
|
+
if (!existsSync(issuesDir)) {
|
|
104
|
+
return `${prefix}-1`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let max = 0;
|
|
108
|
+
const pattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d+)$`);
|
|
109
|
+
|
|
110
|
+
for (const name of readdirSync(issuesDir)) {
|
|
111
|
+
const m = name.match(pattern);
|
|
112
|
+
if (m) {
|
|
113
|
+
const n = parseInt(m[1], 10);
|
|
114
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `${prefix}-${max + 1}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Return the N most recently updated issues at phase=land (or any specified phase).
|
|
123
|
+
* Useful when listIssues() default returns empty and a fresh session needs context.
|
|
124
|
+
*/
|
|
125
|
+
export function recentLandedIssues(input: { root?: string; limit?: number; phase?: string } = {}): IssueListEntry[] {
|
|
126
|
+
const root = input.root ?? process.cwd();
|
|
127
|
+
const limit = input.limit ?? 5;
|
|
128
|
+
const targetPhase = input.phase ?? "land";
|
|
129
|
+
|
|
130
|
+
const all = listIssues({ root, includeAll: true });
|
|
131
|
+
return all.filter((e) => e.currentPhase === targetPhase).slice(0, limit);
|
|
132
|
+
}
|
package/core/land.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hasArtifact, readArtifact, rewriteArtifact } from "./artifacts";
|
|
2
|
+
import { createPhaseArtifactInitializer } from "./phase-artifact";
|
|
3
|
+
import { readIssueState, setIssueArchived, type StateEvent } from "./state";
|
|
4
|
+
|
|
5
|
+
export interface LandFindingsPayload {
|
|
6
|
+
landReady?: boolean;
|
|
7
|
+
mergePlan?: string;
|
|
8
|
+
qaFindingsArtifact?: string;
|
|
9
|
+
releaseRisks?: unknown[];
|
|
10
|
+
status?: string;
|
|
11
|
+
summary?: string;
|
|
12
|
+
mergedAt?: string;
|
|
13
|
+
mergedSha?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LandFindingsReconcileResult {
|
|
18
|
+
reconciled: boolean;
|
|
19
|
+
reason: string;
|
|
20
|
+
mergedAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
24
|
+
|
|
25
|
+
export const initializeLandFindings = createPhaseArtifactInitializer({
|
|
26
|
+
artifactType: "land-findings",
|
|
27
|
+
label: "Land findings",
|
|
28
|
+
payload: {
|
|
29
|
+
landReady: false,
|
|
30
|
+
mergePlan: "",
|
|
31
|
+
qaFindingsArtifact: "qa-findings",
|
|
32
|
+
releaseRisks: [],
|
|
33
|
+
status: "draft",
|
|
34
|
+
summary: "",
|
|
35
|
+
},
|
|
36
|
+
requiredPhase: "qa",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function reconcileLandFindings(input: {
|
|
40
|
+
root?: string;
|
|
41
|
+
issueId: string;
|
|
42
|
+
sha: string;
|
|
43
|
+
}): LandFindingsReconcileResult {
|
|
44
|
+
if (!GIT_SHA_PATTERN.test(input.sha)) {
|
|
45
|
+
throw new Error(`Invalid git sha: ${input.sha}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const root = input.root ?? process.cwd();
|
|
49
|
+
const state = readIssueState({ root, issueId: input.issueId });
|
|
50
|
+
if (state.currentPhase !== "land") {
|
|
51
|
+
return { reconciled: false, reason: `phase=${state.currentPhase} is not land` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!hasArtifact({ root, issueId: input.issueId, type: "land-findings" })) {
|
|
55
|
+
return { reconciled: false, reason: "land-findings artifact missing" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const artifact = readArtifact({ root, issueId: input.issueId, type: "land-findings" });
|
|
59
|
+
const currentPayload = asPayloadRecord(artifact.payload);
|
|
60
|
+
if (
|
|
61
|
+
currentPayload.status === "landed" &&
|
|
62
|
+
currentPayload.mergedSha === input.sha &&
|
|
63
|
+
typeof currentPayload.mergedAt === "string"
|
|
64
|
+
) {
|
|
65
|
+
return { reconciled: false, reason: "land-findings already reconciled", mergedAt: currentPayload.mergedAt };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mergedAt = new Date().toISOString();
|
|
69
|
+
const payload: LandFindingsPayload = {
|
|
70
|
+
...currentPayload,
|
|
71
|
+
status: "landed",
|
|
72
|
+
mergedAt,
|
|
73
|
+
mergedSha: input.sha,
|
|
74
|
+
};
|
|
75
|
+
const event: StateEvent = {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
type: "artifact.reconciled",
|
|
78
|
+
issueId: input.issueId,
|
|
79
|
+
timestamp: mergedAt,
|
|
80
|
+
payload: {
|
|
81
|
+
artifactType: "land-findings",
|
|
82
|
+
path: "artifacts/land-findings.json",
|
|
83
|
+
mergedAt,
|
|
84
|
+
mergedSha: input.sha,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
rewriteArtifact({
|
|
89
|
+
root,
|
|
90
|
+
issueId: input.issueId,
|
|
91
|
+
type: "land-findings",
|
|
92
|
+
payload,
|
|
93
|
+
timestamp: mergedAt,
|
|
94
|
+
event,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Auto-archive issue on successful reconcile
|
|
98
|
+
setIssueArchived({ root, issueId: input.issueId, archived: true });
|
|
99
|
+
|
|
100
|
+
return { reconciled: true, reason: "land-findings reconciled", mergedAt };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function asPayloadRecord(payload: unknown): LandFindingsPayload {
|
|
104
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
return payload as LandFindingsPayload;
|
|
108
|
+
}
|