@warpgogol/forge 2.8.0 → 2.8.2
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 +1 -1
- package/README.md +23 -13
- package/README.uk.md +19 -12
- package/dist/os/session/handlers/validate.d.ts.map +1 -1
- package/dist/os/session/handlers/validate.js +23 -1
- package/dist/os/session/handlers/validate.js.map +1 -1
- package/dist/os/session/types.d.ts +31 -1
- package/dist/os/session/types.d.ts.map +1 -1
- package/dist/os/session/types.js +7 -0
- package/dist/os/session/types.js.map +1 -1
- package/os/adr/adr.module.ts +158 -0
- package/os/adr/frontmatter-io.ts +80 -0
- package/os/adr/handlers/archive.ts +244 -0
- package/os/adr/handlers/implement-stamp.ts +331 -0
- package/os/adr/handlers/list-create.ts +207 -0
- package/os/adr/handlers/validate.test.ts +232 -0
- package/os/adr/handlers/validate.ts +429 -0
- package/os/adr/index.ts +33 -0
- package/os/adr/types.ts +141 -0
- package/os/audit/audit.module.ts +48 -0
- package/os/audit/frontmatter-io.ts +84 -0
- package/os/audit/handlers/archive.ts +239 -0
- package/os/audit/index.ts +23 -0
- package/os/audit/types.ts +29 -0
- package/os/compass/compass.module.ts +186 -0
- package/os/compass/handlers/compass-audit-handler.ts +382 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
- package/os/compass/handlers/compass-inventory-handler.ts +294 -0
- package/os/compass/handlers/compass-inventory.ts +521 -0
- package/os/compass/handlers/git-revision.ts +137 -0
- package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
- package/os/compass/handlers/resolve-scan-root.ts +79 -0
- package/os/compass/index.ts +33 -0
- package/os/core/core.module.ts +948 -0
- package/os/core/handlers/assets-check.ts +122 -0
- package/os/core/handlers/assets-helpers.ts +174 -0
- package/os/core/handlers/assets-list.ts +99 -0
- package/os/core/handlers/build.ts +155 -0
- package/os/core/handlers/determinism-check.ts +354 -0
- package/os/core/handlers/dev.ts +127 -0
- package/os/core/handlers/invariant-engine.test.ts +681 -0
- package/os/core/handlers/knowledge-compact.ts +248 -0
- package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
- package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
- package/os/core/handlers/note-link-validate.test.ts +117 -0
- package/os/core/handlers/note-orphan-detect.test.ts +94 -0
- package/os/core/handlers/package-health.test.ts +226 -0
- package/os/core/handlers/package-health.ts +231 -0
- package/os/core/handlers/pinned-check.ts +200 -0
- package/os/core/handlers/pinned-init.ts +333 -0
- package/os/core/handlers/pinned-types.ts +50 -0
- package/os/core/handlers/pinned-validate.ts +301 -0
- package/os/core/handlers/profile-resolve.ts +92 -0
- package/os/core/handlers/release-prepare.ts +294 -0
- package/os/core/handlers/release-publish.ts +211 -0
- package/os/core/handlers/validate.ts +262 -0
- package/os/core/handlers/workspace-deps.ts +55 -0
- package/os/core/index.ts +13 -0
- package/os/exploration/exploration.module.ts +90 -0
- package/os/exploration/frontmatter-io.ts +77 -0
- package/os/exploration/handlers/archive.ts +159 -0
- package/os/exploration/handlers/list.ts +77 -0
- package/os/exploration/handlers/show.ts +107 -0
- package/os/exploration/index.ts +25 -0
- package/os/exploration/types.ts +65 -0
- package/os/mission/handlers/archive.test.ts +391 -0
- package/os/mission/handlers/archive.ts +453 -0
- package/os/mission/index.ts +1 -0
- package/os/mission/mission.module.ts +48 -0
- package/os/mission/types.ts +41 -0
- package/os/naming/index.ts +14 -0
- package/os/naming/naming-convention.test.ts +163 -0
- package/os/naming/naming-convention.ts +378 -0
- package/os/naming/naming.module.ts +36 -0
- package/os/notes/index.ts +10 -0
- package/os/notes/notes.module.ts +109 -0
- package/os/plan/frontmatter-io.ts +82 -0
- package/os/plan/handlers/archive.ts +238 -0
- package/os/plan/index.ts +23 -0
- package/os/plan/plan.module.ts +48 -0
- package/os/plan/types.ts +29 -0
- package/os/program/discovery.ts +294 -0
- package/os/program/handlers/complete.ts +419 -0
- package/os/program/handlers/lease.ts +433 -0
- package/os/program/handlers/seal.ts +332 -0
- package/os/program/handlers/validate.ts +213 -0
- package/os/program/lease.ts +156 -0
- package/os/program/program.module.ts +212 -0
- package/os/program/schemas.ts +218 -0
- package/os/program/state.ts +264 -0
- package/os/rfc/acceptance.ts +344 -0
- package/os/rfc/decision-log.ts +310 -0
- package/os/rfc/dna-trace.ts +280 -0
- package/os/rfc/frontmatter-io.test.ts +132 -0
- package/os/rfc/frontmatter-io.ts +130 -0
- package/os/rfc/handlers/archive.ts +300 -0
- package/os/rfc/handlers/check.ts +238 -0
- package/os/rfc/handlers/implement-stamp.ts +515 -0
- package/os/rfc/handlers/index-graph.ts +237 -0
- package/os/rfc/handlers/lifecycle.test.ts +127 -0
- package/os/rfc/handlers/lifecycle.ts +249 -0
- package/os/rfc/handlers/list-create.ts +296 -0
- package/os/rfc/handlers/pipeline-status.ts +173 -0
- package/os/rfc/handlers/shared.ts +121 -0
- package/os/rfc/handlers/supersede-propose.ts +241 -0
- package/os/rfc/handlers/validate-rules.test.ts +891 -0
- package/os/rfc/handlers/validate-rules.ts +984 -0
- package/os/rfc/handlers/validate.ts +166 -0
- package/os/rfc/handlers.ts +19 -0
- package/os/rfc/index.ts +97 -0
- package/os/rfc/rfc.module.ts +416 -0
- package/os/rfc/types.ts +596 -0
- package/os/rfc/verification-evidence.ts +244 -0
- package/os/session/atif-parser.ts +166 -0
- package/os/session/frontmatter-io.ts +163 -0
- package/os/session/handlers/archive.ts +288 -0
- package/os/session/handlers/list.ts +174 -0
- package/os/session/handlers/save.ts +348 -0
- package/os/session/handlers/validate.ts +250 -0
- package/os/session/index.ts +50 -0
- package/os/session/session.module.ts +126 -0
- package/os/session/types.ts +203 -0
- package/os/spec/live-spec-list-show-validate.test.ts +257 -0
- package/os/spec/live-spec-list.ts +78 -0
- package/os/spec/live-spec-merge.test.ts +261 -0
- package/os/spec/live-spec-merge.ts +413 -0
- package/os/spec/live-spec-show.ts +117 -0
- package/os/spec/live-spec-types.ts +93 -0
- package/os/spec/live-spec-validate.ts +170 -0
- package/os/spec/spec-materialize.ts +390 -0
- package/os/spec/spec-schema.ts +174 -0
- package/os/spec/spec-status.ts +284 -0
- package/os/spec/spec-validate.ts +513 -0
- package/os/spec/spec.module.ts +135 -0
- package/os/werkstatt/handlers/lock.ts +168 -0
- package/os/werkstatt/handlers/schema.ts +47 -0
- package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
- package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
- package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
- package/os/werkstatt/index.ts +39 -0
- package/os/werkstatt/werkstatt.module.ts +62 -0
- package/os/workflow/handlers.ts +304 -0
- package/os/workflow/index.ts +28 -0
- package/os/workflow/types.ts +99 -0
- package/os/workflow/workflow.module.ts +54 -0
- package/package.json +3 -3
- package/profiles/godot-csharp.yaml +4 -4
- package/profiles/knowledge-typescript-turborepo.yaml +110 -0
- package/profiles/phaser-turborepo.yaml +5 -5
- package/skills/_shared/fo-session-summary.md +132 -16
- package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
- package/skills/fo/fo-doc-audit/SKILL.md +6 -6
- package/skills/fo/fo-handoff/SKILL.md +5 -0
- package/skills/fo/fo-idea/SKILL.md +1 -1
- package/skills/fo/fo-review/SKILL.md +1 -1
- package/skills/fo/fo-session-retro/SKILL.md +8 -0
- package/skills/fo/fo-session-save/SKILL.md +8 -0
- package/src/cli-output.ts +59 -0
- package/src/config/__tests__/resolve-terminology.test.ts +45 -0
- package/src/config/forge-config.ts +524 -0
- package/src/forge-module.ts +39 -0
- package/src/index.ts +172 -0
- package/src/knowledge/__tests__/promote.test.ts +219 -0
- package/src/knowledge/__tests__/serialize.test.ts +192 -0
- package/src/knowledge/budgets.ts +202 -0
- package/src/knowledge/compact.ts +405 -0
- package/src/knowledge/index.ts +53 -0
- package/src/knowledge/parse.ts +227 -0
- package/src/knowledge/promote.ts +156 -0
- package/src/knowledge/schema.ts +112 -0
- package/src/knowledge/serialize.ts +72 -0
- package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
- package/src/migration-adapters/__tests__/registry.test.ts +131 -0
- package/src/migration-adapters/__tests__/types.test.ts +86 -0
- package/src/migration-adapters/git-utils.ts +71 -0
- package/src/migration-adapters/ignored-files.ts +221 -0
- package/src/migration-adapters/index.ts +21 -0
- package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
- package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
- package/src/migration-adapters/registry.ts +61 -0
- package/src/migration-adapters/types.ts +68 -0
- package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
- package/src/onboarding/agents-generate.ts +443 -0
- package/src/onboarding/create.ts +427 -0
- package/src/onboarding/doctor.ts +1297 -0
- package/src/onboarding/init.ts +337 -0
- package/src/onboarding/invariant-engine.ts +448 -0
- package/src/onboarding/memory-scaffold.ts +189 -0
- package/src/onboarding/nested-agents-generate.ts +93 -0
- package/src/onboarding/nested-agents-templates.ts +233 -0
- package/src/onboarding/profile-validate.ts +139 -0
- package/src/onboarding/scaffold-project.ts +299 -0
- package/src/onboarding/scaffold.ts +143 -0
- package/src/onboarding/upgrade.ts +558 -0
- package/src/onboarding/workspace-discovery.ts +167 -0
- package/src/profiles/__tests__/profile-schema.test.ts +228 -0
- package/src/profiles/__tests__/stack-profile.test.ts +190 -0
- package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
- package/src/profiles/profile-schema.ts +350 -0
- package/src/profiles/stack-profile.ts +188 -0
- package/src/profiles/terminology-utils.ts +36 -0
- package/src/registry.ts +185 -0
- package/src/skill-schema.ts +37 -0
- package/src/tests/acceptance-criteria.test.ts +99 -0
- package/src/tests/adr-implement-stamp.test.ts +368 -0
- package/src/tests/agents-generate-domain.test.ts +317 -0
- package/src/tests/agents-generate.test.ts +458 -0
- package/src/tests/bindings-schema.test.ts +314 -0
- package/src/tests/budgets.test.ts +213 -0
- package/src/tests/cli-output.test.ts +196 -0
- package/src/tests/compact.test.ts +796 -0
- package/src/tests/create.test.ts +289 -0
- package/src/tests/doctor-autonomy.test.ts +98 -0
- package/src/tests/doctor-bindings.test.ts +286 -0
- package/src/tests/doctor-domain.test.ts +113 -0
- package/src/tests/exploration-handlers.test.ts +256 -0
- package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
- package/src/tests/forge-config.test.ts +420 -0
- package/src/tests/fs-atomic.test.ts +96 -0
- package/src/tests/fs.test.ts +103 -0
- package/src/tests/generated-marker.test.ts +161 -0
- package/src/tests/hash.test.ts +37 -0
- package/src/tests/implement-stamp.test.ts +505 -0
- package/src/tests/init-bindings.test.ts +183 -0
- package/src/tests/knowledge-parse.test.ts +307 -0
- package/src/tests/knowledge-pbt.test.ts +164 -0
- package/src/tests/memory-scaffold.test.ts +192 -0
- package/src/tests/migration-adapters.test.ts +480 -0
- package/src/tests/package-files.test.ts +54 -0
- package/src/tests/pinned-check.test.ts +194 -0
- package/src/tests/pinned-init.test.ts +126 -0
- package/src/tests/profile-schema.test.ts +194 -0
- package/src/tests/profile-validate.test.ts +120 -0
- package/src/tests/program-lease.test.ts +245 -0
- package/src/tests/program-paths-extended.test.ts +117 -0
- package/src/tests/program-paths.test.ts +98 -0
- package/src/tests/program-property.test.ts +139 -0
- package/src/tests/program-schemas.test.ts +445 -0
- package/src/tests/program-spec-node.test.ts +250 -0
- package/src/tests/promote.test.ts +481 -0
- package/src/tests/registry.test.ts +73 -0
- package/src/tests/scaffold-project.test.ts +150 -0
- package/src/tests/session-handlers.test.ts +503 -0
- package/src/tests/session-pbt.test.ts +190 -0
- package/src/tests/skill-schema.test.ts +146 -0
- package/src/tests/skill-validate-knowledge.test.ts +26 -0
- package/src/tests/skill-validate.test.ts +254 -0
- package/src/tests/stack-profile.test.ts +221 -0
- package/src/tests/string-utils.test.ts +42 -0
- package/src/tests/upgrade.test.ts +428 -0
- package/src/tests/werkstatt-lock.test.ts +343 -0
- package/src/tests/workspace-discovery-domain.test.ts +112 -0
- package/src/tests/workspace-discovery.test.ts +135 -0
- package/src/types.ts +221 -0
- package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
- package/src/utils/__tests__/fs-trash.test.ts +45 -0
- package/src/utils/fs-atomic.ts +93 -0
- package/src/utils/fs-idempotent.ts +41 -0
- package/src/utils/fs-trash-sync.ts +37 -0
- package/src/utils/fs-trash.ts +24 -0
- package/src/utils/fs.ts +74 -0
- package/src/utils/generated-marker.ts +166 -0
- package/src/utils/hash.ts +19 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/string-utils.ts +19 -0
- package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
- package/src/validators/note-frontmatter-validate.ts +142 -0
- package/src/validators/note-link-validate.ts +145 -0
- package/src/validators/note-orphan-detect.ts +146 -0
- package/src/validators/port-validate.ts +97 -0
- package/src/validators/skill-validate.ts +831 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
ADR archive handler — moves terminal-status ADR files into status-specific
|
|
5
|
+
subdirectories under docs/adrs/archive/ and moves non-terminal files found
|
|
6
|
+
in subdirectories back to the root.
|
|
7
|
+
</purpose>
|
|
8
|
+
<non-goals>
|
|
9
|
+
<item>Does not validate ADR content — use adr.validate for that.</item>
|
|
10
|
+
<item>Does not change ADR frontmatter — only moves files on disk.</item>
|
|
11
|
+
</non-goals>
|
|
12
|
+
</MODULE_CONTRACT>
|
|
13
|
+
<CHANGE_SUMMARY>
|
|
14
|
+
<item>RFC-0367: implement adr.archive command for terminal-status file archiving.</item>
|
|
15
|
+
<item>RFC-0521: migrated from packages/os/site-kernel/src/adr/ to packages/forge/os/adr/.</item>
|
|
16
|
+
<item>RFC-0733: add pinned-files pre-check — skip pinned files with warning instead of moving them.</item>
|
|
17
|
+
</CHANGE_SUMMARY>
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { listAdrFiles, readAndParseAdr } from "../frontmatter-io.ts";
|
|
23
|
+
import type {
|
|
24
|
+
ForgeCommandInput,
|
|
25
|
+
ForgeCommandResult,
|
|
26
|
+
ForgeRuntimeContext,
|
|
27
|
+
} from "../../../src/types.ts";
|
|
28
|
+
import { ADR_DIR } from "../types.ts";
|
|
29
|
+
import { loadPinnedManifest, isPinned, isIntraDirMove } from "../../core/handlers/pinned-check.ts";
|
|
30
|
+
|
|
31
|
+
export const ADR_TERMINAL_STATUSES = ["implemented", "rejected", "superseded"] as const;
|
|
32
|
+
|
|
33
|
+
export interface ArchiveMove {
|
|
34
|
+
id: string;
|
|
35
|
+
file: string;
|
|
36
|
+
status: string;
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
direction: "into-archive" | "out-of-archive";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ArchiveSkip {
|
|
43
|
+
id: string;
|
|
44
|
+
file: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AdrArchiveResult {
|
|
49
|
+
command: "adr.archive";
|
|
50
|
+
status: "ok";
|
|
51
|
+
moved: ArchiveMove[];
|
|
52
|
+
skipped: ArchiveSkip[];
|
|
53
|
+
dryRun: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runAdrArchive(
|
|
57
|
+
input: ForgeCommandInput,
|
|
58
|
+
context: ForgeRuntimeContext,
|
|
59
|
+
): Promise<ForgeCommandResult<AdrArchiveResult>> {
|
|
60
|
+
const { workspaceRoot, logger, outputFormat } = context;
|
|
61
|
+
const adrDirPath = path.join(workspaceRoot, ADR_DIR);
|
|
62
|
+
|
|
63
|
+
const dryRun = context.dryRun || input.flags["dry-run"] === true;
|
|
64
|
+
const statusFilter = input.flags["status"] as string | undefined;
|
|
65
|
+
|
|
66
|
+
if (statusFilter && !ADR_TERMINAL_STATUSES.includes(statusFilter as never)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Invalid --status "${statusFilter}". Must be one of: ${ADR_TERMINAL_STATUSES.join(", ")}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const files = await listAdrFiles(adrDirPath);
|
|
73
|
+
const moved: ArchiveMove[] = [];
|
|
74
|
+
const skipped: ArchiveSkip[] = [];
|
|
75
|
+
|
|
76
|
+
// RFC-0733: Load pinned manifest once per invocation
|
|
77
|
+
let pinnedManifest = null;
|
|
78
|
+
try {
|
|
79
|
+
pinnedManifest = await loadPinnedManifest(workspaceRoot);
|
|
80
|
+
} catch {
|
|
81
|
+
// Malformed manifest — skip pre-check
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const fileName of files) {
|
|
85
|
+
const result = await readAndParseAdr(adrDirPath, fileName);
|
|
86
|
+
if (!result) {
|
|
87
|
+
skipped.push({
|
|
88
|
+
id: "UNKNOWN",
|
|
89
|
+
file: path.join(ADR_DIR, fileName),
|
|
90
|
+
reason: "unreadable frontmatter",
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fm = result.parsed.frontmatter;
|
|
96
|
+
const id = String(fm["id"] ?? "UNKNOWN");
|
|
97
|
+
const status = String(fm["status"] ?? "").trim();
|
|
98
|
+
const isTerminal = status === "implemented" || status === "rejected" || status === "superseded";
|
|
99
|
+
const isInArchive = fileName.includes("/");
|
|
100
|
+
const relFile = path.join(ADR_DIR, fileName);
|
|
101
|
+
const basename = path.basename(fileName);
|
|
102
|
+
|
|
103
|
+
if (statusFilter && status !== statusFilter) {
|
|
104
|
+
skipped.push({
|
|
105
|
+
id,
|
|
106
|
+
file: relFile,
|
|
107
|
+
reason: `status ${status} does not match --status ${statusFilter}`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isTerminal && !isInArchive) {
|
|
113
|
+
const targetDir = path.join(adrDirPath, "archive", status);
|
|
114
|
+
const targetPath = path.join(targetDir, basename);
|
|
115
|
+
const targetRel = path.join(ADR_DIR, "archive", status, basename);
|
|
116
|
+
|
|
117
|
+
// RFC-0733: Check if file is pinned before moving
|
|
118
|
+
// Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
|
|
119
|
+
if (
|
|
120
|
+
pinnedManifest &&
|
|
121
|
+
isPinned(pinnedManifest, relFile) &&
|
|
122
|
+
!isIntraDirMove(pinnedManifest, relFile, targetRel)
|
|
123
|
+
) {
|
|
124
|
+
skipped.push({ id, file: relFile, reason: "pinned (protected by .forge/pinned.yaml)" });
|
|
125
|
+
if (outputFormat === "pretty") {
|
|
126
|
+
logger.warn(` pinned: skipping ${relFile} (protected)`);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await fs.access(targetPath);
|
|
133
|
+
skipped.push({ id, file: relFile, reason: "destination exists" });
|
|
134
|
+
continue;
|
|
135
|
+
} catch {
|
|
136
|
+
// destination doesn't exist — proceed
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!dryRun) {
|
|
140
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
141
|
+
try {
|
|
142
|
+
await fs.rename(path.join(adrDirPath, fileName), targetPath);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
145
|
+
skipped.push({ id, file: relFile, reason: "already moved by another process" });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
moved.push({
|
|
153
|
+
id,
|
|
154
|
+
file: targetRel,
|
|
155
|
+
status,
|
|
156
|
+
from: relFile,
|
|
157
|
+
to: targetRel,
|
|
158
|
+
direction: "into-archive",
|
|
159
|
+
});
|
|
160
|
+
} else if (!isTerminal && isInArchive) {
|
|
161
|
+
const targetPath = path.join(adrDirPath, basename);
|
|
162
|
+
const targetRel = path.join(ADR_DIR, basename);
|
|
163
|
+
|
|
164
|
+
// RFC-0733: Check if file is pinned before moving
|
|
165
|
+
// Gap fix: exempt intra-directory moves (file stays within the same pinned dir)
|
|
166
|
+
if (
|
|
167
|
+
pinnedManifest &&
|
|
168
|
+
isPinned(pinnedManifest, relFile) &&
|
|
169
|
+
!isIntraDirMove(pinnedManifest, relFile, targetRel)
|
|
170
|
+
) {
|
|
171
|
+
skipped.push({ id, file: relFile, reason: "pinned (protected by .forge/pinned.yaml)" });
|
|
172
|
+
if (outputFormat === "pretty") {
|
|
173
|
+
logger.warn(` pinned: skipping ${relFile} (protected)`);
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await fs.access(targetPath);
|
|
180
|
+
skipped.push({ id, file: relFile, reason: "destination exists" });
|
|
181
|
+
continue;
|
|
182
|
+
} catch {
|
|
183
|
+
// destination doesn't exist — proceed
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!dryRun) {
|
|
187
|
+
try {
|
|
188
|
+
await fs.rename(path.join(adrDirPath, fileName), targetPath);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
191
|
+
skipped.push({ id, file: relFile, reason: "already moved by another process" });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
moved.push({
|
|
199
|
+
id,
|
|
200
|
+
file: targetRel,
|
|
201
|
+
status,
|
|
202
|
+
from: relFile,
|
|
203
|
+
to: targetRel,
|
|
204
|
+
direction: "out-of-archive",
|
|
205
|
+
});
|
|
206
|
+
} else if (isTerminal && isInArchive) {
|
|
207
|
+
skipped.push({ id, file: relFile, reason: `already archived (${status})` });
|
|
208
|
+
} else {
|
|
209
|
+
skipped.push({ id, file: relFile, reason: `status ${status} is non-terminal` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (outputFormat === "pretty") {
|
|
214
|
+
if (dryRun) {
|
|
215
|
+
logger.info(
|
|
216
|
+
`[dry-run] adr.archive: would move ${moved.length} file(s), skip ${skipped.length}`,
|
|
217
|
+
);
|
|
218
|
+
} else {
|
|
219
|
+
logger.success(`adr.archive: moved ${moved.length} file(s), skipped ${skipped.length}`);
|
|
220
|
+
}
|
|
221
|
+
for (const m of moved) {
|
|
222
|
+
logger.info(` ${m.direction}: ${m.id} (${m.status}) ${m.from} → ${m.to}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
data: {
|
|
228
|
+
command: "adr.archive",
|
|
229
|
+
status: "ok",
|
|
230
|
+
moved,
|
|
231
|
+
skipped,
|
|
232
|
+
dryRun,
|
|
233
|
+
},
|
|
234
|
+
summary: dryRun
|
|
235
|
+
? `[dry-run] Would move ${moved.length} file(s), skip ${skipped.length}`
|
|
236
|
+
: `Moved ${moved.length} file(s), skipped ${skipped.length}`,
|
|
237
|
+
nextSteps: [
|
|
238
|
+
{
|
|
239
|
+
action: "Run: pnpm exec werkstatt run adr.list --json to verify archive status",
|
|
240
|
+
kind: "optional",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
RFC-0727: adr.implement.stamp — the exclusive atomic path for
|
|
5
|
+
accepted/proposed → implemented ADR transitions. Validates preconditions
|
|
6
|
+
and atomically mutates ADR frontmatter.
|
|
7
|
+
</purpose>
|
|
8
|
+
<non-goals>
|
|
9
|
+
<item>Does not transition ADRs not in accepted or proposed status.</item>
|
|
10
|
+
<item>Does not add acceptance criteria or evidence checks — ADRs do not have these.</item>
|
|
11
|
+
<item>Does not call the GitHub API or require network access.</item>
|
|
12
|
+
</non-goals>
|
|
13
|
+
</MODULE_CONTRACT>
|
|
14
|
+
<CHANGE_SUMMARY>
|
|
15
|
+
<item>RFC-0727: initial implementation — mirrors rfc.implement.stamp for ADRs.</item>
|
|
16
|
+
</CHANGE_SUMMARY>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { readFile, unlink, mkdir } from "node:fs/promises";
|
|
21
|
+
import { join, dirname } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
|
|
24
|
+
|
|
25
|
+
import { listAdrFiles, readAndParseAdr, adrFileMatchesId } from "../frontmatter-io.ts";
|
|
26
|
+
import { ADR_DIR } from "../types.ts";
|
|
27
|
+
import type { AdrStatus, AdrImplementStampViolation, AdrImplementStampResult } from "../types.ts";
|
|
28
|
+
import type {
|
|
29
|
+
ForgeCommandInput,
|
|
30
|
+
ForgeCommandResult,
|
|
31
|
+
ForgeRuntimeContext,
|
|
32
|
+
} from "../../../src/types.ts";
|
|
33
|
+
|
|
34
|
+
// ─── Git helpers ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function execGit(workspaceRoot: string, args: string[]): Promise<string> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
execFile("git", args, { cwd: workspaceRoot, timeout: 10000 }, (err, stdout) => {
|
|
39
|
+
if (err) {
|
|
40
|
+
resolve("");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
resolve(stdout.trim());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function isAdrFileClean(workspaceRoot: string, adrRelPath: string): Promise<boolean> {
|
|
49
|
+
const status = await execGit(workspaceRoot, ["status", "--porcelain", "--", adrRelPath]);
|
|
50
|
+
return status.length === 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function commitReachableFromHead(workspaceRoot: string, commitSha: string): Promise<boolean> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
execFile(
|
|
56
|
+
"git",
|
|
57
|
+
["merge-base", "--is-ancestor", commitSha, "HEAD"],
|
|
58
|
+
{ cwd: workspaceRoot, timeout: 10000 },
|
|
59
|
+
(err) => {
|
|
60
|
+
resolve(!err);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function commitReferencesAdr(
|
|
67
|
+
workspaceRoot: string,
|
|
68
|
+
commitSha: string,
|
|
69
|
+
adrId: string,
|
|
70
|
+
): Promise<boolean> {
|
|
71
|
+
const commitMsg = await execGit(workspaceRoot, ["log", "-1", "--format=%B", commitSha]);
|
|
72
|
+
if (commitMsg.includes(adrId)) return true;
|
|
73
|
+
|
|
74
|
+
const changedFiles = await execGit(workspaceRoot, [
|
|
75
|
+
"show",
|
|
76
|
+
"--name-only",
|
|
77
|
+
"--format=",
|
|
78
|
+
commitSha,
|
|
79
|
+
]);
|
|
80
|
+
const slug = adrId.toLowerCase();
|
|
81
|
+
if (changedFiles.toLowerCase().includes(slug)) return true;
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── ADR-specific exclusive lock ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function lockFilePath(workspaceRoot: string, adrId: string): string {
|
|
89
|
+
const slug = adrId.toLowerCase();
|
|
90
|
+
return join(workspaceRoot, ".adr-locks", `${slug}.lock`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function acquireAdrLock(
|
|
94
|
+
workspaceRoot: string,
|
|
95
|
+
adrId: string,
|
|
96
|
+
): Promise<{ acquired: boolean; lockPath: string }> {
|
|
97
|
+
const lockPath = lockFilePath(workspaceRoot, adrId);
|
|
98
|
+
const lockDir = dirname(lockPath);
|
|
99
|
+
await mkdir(lockDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const { open } = await import("node:fs/promises");
|
|
102
|
+
try {
|
|
103
|
+
const handle = await open(lockPath, "wx");
|
|
104
|
+
await handle.writeFile(
|
|
105
|
+
JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }),
|
|
106
|
+
);
|
|
107
|
+
await handle.close();
|
|
108
|
+
return { acquired: true, lockPath };
|
|
109
|
+
} catch {
|
|
110
|
+
return { acquired: false, lockPath };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function releaseAdrLock(lockPath: string): Promise<void> {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(lockPath);
|
|
117
|
+
} catch {
|
|
118
|
+
// Lock file already removed — safe
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Frontmatter mutation ────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function mutateAdrFrontmatter(
|
|
125
|
+
source: string,
|
|
126
|
+
status: "implemented",
|
|
127
|
+
implementedAt: string,
|
|
128
|
+
updatedAt: string,
|
|
129
|
+
): string {
|
|
130
|
+
let content = source;
|
|
131
|
+
|
|
132
|
+
content = content.replace(/^status: \w+$/m, `status: ${status}`);
|
|
133
|
+
|
|
134
|
+
if (/^implementedAt:.*$/m.test(content)) {
|
|
135
|
+
content = content.replace(/^implementedAt:.*$/m, `implementedAt: ${implementedAt}`);
|
|
136
|
+
} else {
|
|
137
|
+
content = content.replace(
|
|
138
|
+
/^updatedAt: .*$/m,
|
|
139
|
+
`updatedAt: ${updatedAt}\nimplementedAt: ${implementedAt}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
content = content.replace(/^updatedAt: .*$/m, `updatedAt: ${updatedAt}`);
|
|
144
|
+
|
|
145
|
+
return content;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toIsoDate(date: Date): string {
|
|
149
|
+
return date.toISOString().split("T")[0]!;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Main handler ────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export async function runAdrImplementStamp(
|
|
155
|
+
input: ForgeCommandInput,
|
|
156
|
+
context: ForgeRuntimeContext,
|
|
157
|
+
): Promise<ForgeCommandResult<AdrImplementStampResult>> {
|
|
158
|
+
const { workspaceRoot, logger, outputFormat, dryRun } = context;
|
|
159
|
+
const adrDirPath = join(workspaceRoot, ADR_DIR);
|
|
160
|
+
|
|
161
|
+
const targetId = input.flags["id"] as string | undefined;
|
|
162
|
+
const implementationCommit = input.flags["implementation-commit"] as string | undefined;
|
|
163
|
+
|
|
164
|
+
// ── Flag validation ───────────────────────────────────────────────────────
|
|
165
|
+
if (!targetId) {
|
|
166
|
+
throw new Error("adr.implement.stamp requires --id flag (e.g. --id ADR-0003).");
|
|
167
|
+
}
|
|
168
|
+
if (!implementationCommit) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"adr.implement.stamp requires --implementation-commit flag (the SHA of the implementation commit).",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const violations: AdrImplementStampViolation[] = [];
|
|
175
|
+
const isDryRun = dryRun || input.flags["dry-run"] === true;
|
|
176
|
+
|
|
177
|
+
// ── Find the target ADR ───────────────────────────────────────────────────
|
|
178
|
+
const files = await listAdrFiles(adrDirPath);
|
|
179
|
+
const targetFile = files.find((f) => adrFileMatchesId(f, targetId));
|
|
180
|
+
|
|
181
|
+
if (!targetFile) {
|
|
182
|
+
violations.push({
|
|
183
|
+
rule: "ADR-IMP-01",
|
|
184
|
+
message: `ADR ${targetId} not found in ${ADR_DIR}.`,
|
|
185
|
+
});
|
|
186
|
+
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const targetParsed = await readAndParseAdr(adrDirPath, targetFile);
|
|
190
|
+
if (!targetParsed) {
|
|
191
|
+
violations.push({
|
|
192
|
+
rule: "ADR-IMP-01",
|
|
193
|
+
message: `Could not parse target ADR ${targetId}.`,
|
|
194
|
+
});
|
|
195
|
+
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fm = targetParsed.parsed.frontmatter;
|
|
199
|
+
const currentStatus = String(fm["status"] ?? "") as AdrStatus;
|
|
200
|
+
|
|
201
|
+
// ── ADR-IMP-01: must be in accepted or proposed status ─────────────────────
|
|
202
|
+
if (currentStatus !== "accepted" && currentStatus !== "proposed") {
|
|
203
|
+
violations.push({
|
|
204
|
+
rule: "ADR-IMP-01",
|
|
205
|
+
message: `ADR ${targetId} has status "${currentStatus}" — only "accepted" or "proposed" ADRs can be stamped to "implemented".`,
|
|
206
|
+
});
|
|
207
|
+
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── ADR-IMP-04: ADR file must be clean (no uncommitted edits to the target ADR) ──
|
|
211
|
+
const adrRelPath = join(ADR_DIR, targetFile);
|
|
212
|
+
const adrFileClean = await isAdrFileClean(workspaceRoot, adrRelPath);
|
|
213
|
+
if (!adrFileClean) {
|
|
214
|
+
violations.push({
|
|
215
|
+
rule: "ADR-IMP-04",
|
|
216
|
+
message: `ADR file ${adrRelPath} has uncommitted changes. Commit the ADR file before stamping.`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── ADR-IMP-03: implementation commit validation ──────────────────────────
|
|
221
|
+
const reachable = await commitReachableFromHead(workspaceRoot, implementationCommit);
|
|
222
|
+
if (!reachable) {
|
|
223
|
+
violations.push({
|
|
224
|
+
rule: "ADR-IMP-03",
|
|
225
|
+
message: `Implementation commit ${implementationCommit} is not reachable from HEAD. The commit must be an ancestor of the current HEAD.`,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
const referencesAdr = await commitReferencesAdr(workspaceRoot, implementationCommit, targetId);
|
|
229
|
+
if (!referencesAdr) {
|
|
230
|
+
violations.push({
|
|
231
|
+
rule: "ADR-IMP-03",
|
|
232
|
+
message: `Implementation commit ${implementationCommit} does not reference ${targetId} in its message or changed files.`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// If any violations found, return without mutation
|
|
238
|
+
if (violations.length > 0) {
|
|
239
|
+
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── ADR-IMP-05: acquire ADR-specific exclusive lock ───────────────────────
|
|
243
|
+
const { acquired, lockPath } = await acquireAdrLock(workspaceRoot, targetId);
|
|
244
|
+
if (!acquired) {
|
|
245
|
+
violations.push({
|
|
246
|
+
rule: "ADR-IMP-05",
|
|
247
|
+
message: `A concurrent stamp operation is in progress for ${targetId}. Wait for it to complete and retry.`,
|
|
248
|
+
});
|
|
249
|
+
return stampFailResult(violations, isDryRun, outputFormat, logger);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
// ── Atomic mutation ──────────────────────────────────────────────────────
|
|
254
|
+
const adrFilePath = join(adrDirPath, targetFile);
|
|
255
|
+
const adrSource = await readFile(adrFilePath, "utf-8");
|
|
256
|
+
|
|
257
|
+
const today = toIsoDate(new Date());
|
|
258
|
+
const stampedAt = new Date().toISOString();
|
|
259
|
+
const mutatedSource = mutateAdrFrontmatter(adrSource, "implemented", today, today);
|
|
260
|
+
|
|
261
|
+
if (isDryRun) {
|
|
262
|
+
if (outputFormat === "pretty") {
|
|
263
|
+
logger.success(`[dry-run] ${targetId} would be stamped as implemented`);
|
|
264
|
+
logger.info(` implementation commit: ${implementationCommit}`);
|
|
265
|
+
logger.info(` stamped at: ${stampedAt}`);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
data: {
|
|
269
|
+
command: "adr.implement.stamp",
|
|
270
|
+
status: "pass",
|
|
271
|
+
data: {
|
|
272
|
+
adrId: targetId,
|
|
273
|
+
implementationCommit,
|
|
274
|
+
stampedAt,
|
|
275
|
+
},
|
|
276
|
+
violations: [],
|
|
277
|
+
},
|
|
278
|
+
exitCode: 0,
|
|
279
|
+
summary: `adr.implement.stamp: dry-run — ${targetId} would be stamped`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await writeFileAtomic(adrFilePath, mutatedSource);
|
|
284
|
+
|
|
285
|
+
if (outputFormat === "pretty") {
|
|
286
|
+
logger.success(`${targetId} stamped as implemented`);
|
|
287
|
+
logger.info(` implementation commit: ${implementationCommit}`);
|
|
288
|
+
logger.info(` stamped at: ${stampedAt}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
data: {
|
|
293
|
+
command: "adr.implement.stamp",
|
|
294
|
+
status: "pass",
|
|
295
|
+
data: {
|
|
296
|
+
adrId: targetId,
|
|
297
|
+
implementationCommit,
|
|
298
|
+
stampedAt,
|
|
299
|
+
},
|
|
300
|
+
violations: [],
|
|
301
|
+
},
|
|
302
|
+
exitCode: 0,
|
|
303
|
+
summary: `adr.implement.stamp: ${targetId} stamped as implemented`,
|
|
304
|
+
};
|
|
305
|
+
} finally {
|
|
306
|
+
await releaseAdrLock(lockPath);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stampFailResult(
|
|
311
|
+
violations: AdrImplementStampViolation[],
|
|
312
|
+
isDryRun: boolean,
|
|
313
|
+
outputFormat: string,
|
|
314
|
+
logger: { warn: (msg: string) => void; info: (msg: string) => void },
|
|
315
|
+
): ForgeCommandResult<AdrImplementStampResult> {
|
|
316
|
+
const prefix = isDryRun ? "[dry-run] " : "";
|
|
317
|
+
if (outputFormat === "pretty") {
|
|
318
|
+
for (const v of violations) {
|
|
319
|
+
logger.warn(`${prefix}${v.rule}: ${v.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
data: {
|
|
324
|
+
command: "adr.implement.stamp",
|
|
325
|
+
status: "fail",
|
|
326
|
+
violations,
|
|
327
|
+
},
|
|
328
|
+
exitCode: 1,
|
|
329
|
+
summary: `${prefix}adr.implement.stamp: ${violations.length} violation(s)`,
|
|
330
|
+
};
|
|
331
|
+
}
|