@warpgogol/forge 5.1.3 → 5.2.3

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 (116) hide show
  1. package/AGENTS.md +3 -1
  2. package/bin/cli.ts +4 -0
  3. package/dist/bin/cli.js +4 -0
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/adr/adr.module.js +1 -1
  6. package/dist/os/adr/adr.module.js.map +1 -1
  7. package/dist/os/compass/compass.module.js +2 -2
  8. package/dist/os/compass/compass.module.js.map +1 -1
  9. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  10. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  12. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  13. package/dist/os/core/core.module.js +1 -1
  14. package/dist/os/core/core.module.js.map +1 -1
  15. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  16. package/dist/os/mission/handlers/archive.js +22 -6
  17. package/dist/os/mission/handlers/archive.js.map +1 -1
  18. package/dist/os/program/lease.js +1 -1
  19. package/dist/os/program/lease.js.map +1 -1
  20. package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
  21. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
  22. package/dist/os/queue/handlers/queue-validate.js +62 -0
  23. package/dist/os/queue/handlers/queue-validate.js.map +1 -0
  24. package/dist/os/queue/index.d.ts +5 -0
  25. package/dist/os/queue/index.d.ts.map +1 -0
  26. package/dist/os/queue/index.js +18 -0
  27. package/dist/os/queue/index.js.map +1 -0
  28. package/dist/os/queue/manifest.d.ts +14 -0
  29. package/dist/os/queue/manifest.d.ts.map +1 -0
  30. package/dist/os/queue/manifest.js +110 -0
  31. package/dist/os/queue/manifest.js.map +1 -0
  32. package/dist/os/queue/queue.module.d.ts +3 -0
  33. package/dist/os/queue/queue.module.d.ts.map +1 -0
  34. package/dist/os/queue/queue.module.js +56 -0
  35. package/dist/os/queue/queue.module.js.map +1 -0
  36. package/dist/os/queue/types.d.ts +25 -0
  37. package/dist/os/queue/types.d.ts.map +1 -0
  38. package/dist/os/queue/types.js +28 -0
  39. package/dist/os/queue/types.js.map +1 -0
  40. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  41. package/dist/os/rfc/handlers/list-create.js +79 -32
  42. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  43. package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
  44. package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
  45. package/dist/os/rfc/handlers/pipeline-status.js +15 -57
  46. package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
  47. package/dist/os/rfc/rfc.module.js +1 -1
  48. package/dist/os/rfc/rfc.module.js.map +1 -1
  49. package/dist/os/spec/spec.module.d.ts.map +1 -1
  50. package/dist/os/spec/spec.module.js +2 -0
  51. package/dist/os/spec/spec.module.js.map +1 -1
  52. package/dist/src/knowledge/index.d.ts +1 -0
  53. package/dist/src/knowledge/index.d.ts.map +1 -1
  54. package/dist/src/knowledge/index.js +1 -0
  55. package/dist/src/knowledge/index.js.map +1 -1
  56. package/dist/src/knowledge/sync.d.ts +24 -0
  57. package/dist/src/knowledge/sync.d.ts.map +1 -0
  58. package/dist/src/knowledge/sync.js +127 -0
  59. package/dist/src/knowledge/sync.js.map +1 -0
  60. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  61. package/dist/src/onboarding/doctor.js +7 -6
  62. package/dist/src/onboarding/doctor.js.map +1 -1
  63. package/dist/src/onboarding/init.d.ts.map +1 -1
  64. package/dist/src/onboarding/init.js +15 -8
  65. package/dist/src/onboarding/init.js.map +1 -1
  66. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  67. package/dist/src/onboarding/upgrade.js +42 -13
  68. package/dist/src/onboarding/upgrade.js.map +1 -1
  69. package/dist/src/pipeline-status.d.ts +62 -0
  70. package/dist/src/pipeline-status.d.ts.map +1 -0
  71. package/dist/src/pipeline-status.js +220 -0
  72. package/dist/src/pipeline-status.js.map +1 -0
  73. package/dist/src/types.d.ts +1 -2
  74. package/dist/src/types.d.ts.map +1 -1
  75. package/dist/src/types.js +1 -0
  76. package/dist/src/types.js.map +1 -1
  77. package/dist/src/utils/fs-atomic.js +1 -1
  78. package/dist/src/utils/fs-atomic.js.map +1 -1
  79. package/os/adr/adr.module.ts +1 -1
  80. package/os/compass/compass.module.ts +2 -2
  81. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  82. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  83. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  84. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  85. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  86. package/os/core/core.module.ts +1 -1
  87. package/os/mission/handlers/archive.test.ts +49 -0
  88. package/os/mission/handlers/archive.ts +21 -6
  89. package/os/program/lease.ts +1 -1
  90. package/os/queue/handlers/queue-validate.ts +83 -0
  91. package/os/queue/index.ts +19 -0
  92. package/os/queue/manifest.ts +183 -0
  93. package/os/queue/queue-validate.test.ts +177 -0
  94. package/os/queue/queue.module.ts +59 -0
  95. package/os/queue/types.ts +49 -0
  96. package/os/rfc/handlers/list-create.ts +83 -35
  97. package/os/rfc/handlers/pipeline-status.ts +24 -67
  98. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  99. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  100. package/os/rfc/rfc-create-hint.test.ts +90 -0
  101. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  102. package/os/rfc/rfc.module.ts +1 -1
  103. package/os/spec/spec.module.ts +2 -0
  104. package/package.json +17 -1
  105. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
  106. package/src/knowledge/__tests__/sync.test.ts +227 -0
  107. package/src/knowledge/index.ts +7 -0
  108. package/src/knowledge/sync.ts +161 -0
  109. package/src/onboarding/doctor.ts +7 -6
  110. package/src/onboarding/init.ts +15 -8
  111. package/src/onboarding/upgrade.ts +47 -16
  112. package/src/pipeline-status.test.ts +192 -0
  113. package/src/pipeline-status.ts +319 -0
  114. package/src/tests/upgrade.test.ts +53 -0
  115. package/src/types.ts +2 -2
  116. package/src/utils/fs-atomic.ts +1 -1
@@ -0,0 +1,124 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1139 AC-5: compass.audit.baseline must not seed ledger entries for
5
+ paths under missions/ or gitignored roots — the tracked ledger only
6
+ accumulates entries for paths that can be audited long-term.
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { parse as yamlParse } from "yaml";
18
+ import { runCompassAuditBaseline } from "../compass-audit-handler.ts";
19
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const logger = {
24
+ section() {},
25
+ info() {},
26
+ warn() {},
27
+ error() {},
28
+ success() {},
29
+ getEvents() {
30
+ return [];
31
+ },
32
+ };
33
+
34
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
35
+ return {
36
+ workspaceRoot,
37
+ site: undefined,
38
+ siteExplicit: false,
39
+ logger: logger as never,
40
+ dryRun: false,
41
+ outputFormat: "json",
42
+ io: {} as never,
43
+ actualState: undefined as never,
44
+ fileIntents: [],
45
+ } as unknown as ForgeRuntimeContext;
46
+ }
47
+
48
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
49
+ return { argv: [], flags } as unknown as ForgeCommandInput;
50
+ }
51
+
52
+ interface LedgerEntry {
53
+ path: string;
54
+ }
55
+
56
+ describe("compass.audit.baseline — RFC-1139 ledger scope", () => {
57
+ let root: string;
58
+
59
+ beforeEach(async () => {
60
+ root = await mkdtemp(join(tmpdir(), "compass-ledger-"));
61
+ // Authored source file (plain .ts, no generated marker → authored entry)
62
+ await mkdir(join(root, "packages", "foo"), { recursive: true });
63
+ await writeFile(join(root, "packages", "foo", "index.ts"), "export const x = 1;\n", "utf8");
64
+ // Mission workpiece file — would be authored but must be excluded
65
+ await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
66
+ recursive: true,
67
+ });
68
+ await writeFile(
69
+ join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
70
+ "export const y = 2;\n",
71
+ "utf8",
72
+ );
73
+ // Gitignored generated dir — excluded via check-ignore
74
+ await mkdir(join(root, "dist"), { recursive: true });
75
+ await writeFile(join(root, "dist", "bundle.ts"), "export const z = 3;\n", "utf8");
76
+ await writeFile(join(root, ".gitignore"), "dist/\nmissions/\n", "utf8");
77
+ // Git repo for getRevisionByPath + check-ignore
78
+ await execFileAsync("git", ["init"], { cwd: root });
79
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
80
+ await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
81
+ await execFileAsync("git", ["add", "."], { cwd: root });
82
+ await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
83
+ });
84
+
85
+ afterEach(async () => {
86
+ await rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ it("AC-5: baseline seeds no missions/ or gitignored ledger entries", async () => {
90
+ const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
91
+ expect(result.exitCode).toBe(0);
92
+
93
+ const ledgerRaw = await readFile(
94
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
95
+ "utf8",
96
+ );
97
+ const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
98
+ const paths = ledger.entries.map((e) => e.path);
99
+
100
+ expect(paths.some((p) => p.startsWith("missions/"))).toBe(false);
101
+ expect(paths.some((p) => p.startsWith("dist/"))).toBe(false);
102
+ expect(paths).toContain("packages/foo/index.ts");
103
+ });
104
+
105
+ it("AC-5: pre-existing missions/ entries are pruned on next baseline", async () => {
106
+ // Seed a ledger with a stale missions/ entry
107
+ await mkdir(join(root, "docs"), { recursive: true });
108
+ await writeFile(
109
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
110
+ `entries:\n - path: missions/acme-m000001/workpiece/src/local.ts\n entityId: ""\n auditedRevision: abc\n auditedHash: def\n auditedAt: "2026-01-01T00:00:00Z"\n verdict: baseline\n agent: system:baseline\n`,
111
+ "utf8",
112
+ );
113
+
114
+ const result = await runCompassAuditBaseline(makeInput(), makeContext(root));
115
+ expect(result.exitCode).toBe(0);
116
+
117
+ const ledgerRaw = await readFile(
118
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
119
+ "utf8",
120
+ );
121
+ const ledger = yamlParse(ledgerRaw) as { entries: LedgerEntry[] };
122
+ expect(ledger.entries.some((e) => e.path.startsWith("missions/"))).toBe(false);
123
+ });
124
+ });
@@ -305,7 +305,7 @@ export async function createForgeCoreModule(): Promise<ForgeModule> {
305
305
  {
306
306
  name: "forge.scaffold",
307
307
  description:
308
- "Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory.",
308
+ "Create a working pnpm + Turborepo monorepo from a stack profile in an empty directory. Required flags: `--profile`, `--name`.",
309
309
  scope: "workspace",
310
310
  supportsAllSites: false,
311
311
  flags: {
@@ -572,4 +572,53 @@ describe("mission.archive", () => {
572
572
  expect(data.skipped).toHaveLength(1);
573
573
  expect(data.skipped[0].reason).toContain("use --clean-orphans");
574
574
  });
575
+
576
+ test("RFC-1138 AC-2: git add -A skipped when archive target is gitignored", async () => {
577
+ execSyncMock.mockReset();
578
+ execSyncMock.mockImplementation((cmd: string) => {
579
+ if (cmd.startsWith("pnpm install")) return "";
580
+ if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
581
+ // git check-ignore exits 0 → path is ignored (missions/ is gitignored)
582
+ if (cmd.startsWith("git check-ignore")) return "";
583
+ return "";
584
+ });
585
+ await writeMissionManifest(missionsDir, "test-r1138-01", "closed");
586
+
587
+ await runMissionArchive(makeInput(), makeContext(tmpDir));
588
+
589
+ const checkIgnoreCall = execSyncMock.mock.calls.find(
590
+ (c) => typeof c[0] === "string" && c[0].startsWith("git check-ignore"),
591
+ );
592
+ expect(checkIgnoreCall).toBeDefined();
593
+
594
+ const addArchiveCall = execSyncMock.mock.calls.find(
595
+ (c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
596
+ );
597
+ expect(addArchiveCall).toBeUndefined();
598
+
599
+ // Lockfile itself is still staged + committed
600
+ const lockfileAdd = execSyncMock.mock.calls.find(
601
+ (c) => typeof c[0] === "string" && c[0] === "git add pnpm-lock.yaml",
602
+ );
603
+ expect(lockfileAdd).toBeDefined();
604
+ });
605
+
606
+ test("RFC-1138 AC-2: git add -A still runs when archive target is NOT gitignored", async () => {
607
+ execSyncMock.mockReset();
608
+ execSyncMock.mockImplementation((cmd: string) => {
609
+ if (cmd.startsWith("pnpm install")) return "";
610
+ if (cmd.startsWith("git status --porcelain pnpm-lock.yaml")) return "M pnpm-lock.yaml";
611
+ // git check-ignore exits 1 → not ignored → execSync throws
612
+ if (cmd.startsWith("git check-ignore")) throw new Error("exit 1");
613
+ return "";
614
+ });
615
+ await writeMissionManifest(missionsDir, "test-r1138-02", "closed");
616
+
617
+ await runMissionArchive(makeInput(), makeContext(tmpDir));
618
+
619
+ const addArchiveCall = execSyncMock.mock.calls.find(
620
+ (c) => typeof c[0] === "string" && c[0].startsWith("git add -A"),
621
+ );
622
+ expect(addArchiveCall).toBeDefined();
623
+ });
575
624
  });
@@ -13,7 +13,6 @@ archive subdirectories back to missions/.
13
13
  </non-goals>
14
14
  </MODULE_CONTRACT>
15
15
  <CHANGE_SUMMARY>
16
- <item>RFC-0733: add pinned-files pre-check — skip pinned mission directories with warning instead of moving them.</item>
17
16
  <item>RFC-0804: auto-refresh pnpm-lock.yaml after directory moves.</item>
18
17
  <item>RFC-0982: fallback state detection for orphaned workpiece dirs (no mission.yaml) via .closed marker and cache-only heuristic; --clean-orphans flag; improved skip reasons.</item>
19
18
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -22,7 +21,8 @@ Mechanical v1 to v2 header migration across the workspace: 942 files rewritten
22
21
  <item>RFC-1097: sweep — packages/forge + services clean
23
22
 
24
23
  Sweep batch 2: real KEY_DECISIONS on 10 files, expanded purposes (CONTRACT-02/PURPOSE-02), headers on mission/index + gen-upstreams, sanitizeItemText in summary.record (literal Compass tags corrupted history), excludedPaths for wrangler types, test-fixtures testPattern. forge+services now 0 diagnostics under --mode error.</item>
25
- <history>RFC-0573, RFC-0801</history>
24
+ <item>RFC-1138: pipeline hygiene — module-scoped exempt entries, archive gitignore guard, dns upsert to deploy phases, promote auto-sync, rfc.create claim protocol, siteHasRuntime filter</item>
25
+ <history>RFC-0573, RFC-0733, RFC-0801</history>
26
26
  </CHANGE_SUMMARY>
27
27
  */
28
28
 
@@ -520,10 +520,25 @@ export async function runMissionArchive(
520
520
  stdio: ["pipe", "pipe", "pipe"],
521
521
  });
522
522
  for (const m of moved) {
523
- execSync(`git add -A ${JSON.stringify(m.to)}`, {
524
- cwd: workspaceRoot,
525
- stdio: ["pipe", "pipe", "pipe"],
526
- });
523
+ // RFC-1138: missions/ is gitignored — `git add` on the archive path is
524
+ // structurally guaranteed to fail. Skip staging when the target is
525
+ // ignored (git check-ignore exits 0 = ignored, 1 = not ignored).
526
+ let targetIgnored = false;
527
+ try {
528
+ execSync(`git check-ignore -q ${JSON.stringify(m.to)}`, {
529
+ cwd: workspaceRoot,
530
+ stdio: ["pipe", "pipe", "pipe"],
531
+ });
532
+ targetIgnored = true;
533
+ } catch {
534
+ targetIgnored = false;
535
+ }
536
+ if (!targetIgnored) {
537
+ execSync(`git add -A ${JSON.stringify(m.to)}`, {
538
+ cwd: workspaceRoot,
539
+ stdio: ["pipe", "pipe", "pipe"],
540
+ });
541
+ }
527
542
  if (existsSync(m.from)) continue;
528
543
  execSync(`git rm -r --cached --ignore-unmatch ${JSON.stringify(m.from)}`, {
529
544
  cwd: workspaceRoot,
@@ -38,7 +38,7 @@ export function leasePath(workspaceRoot: string, program: string, packetId: stri
38
38
  * Generate a random opaque lease token (32 bytes hex).
39
39
  */
40
40
  export function generateLeaseToken(): string {
41
- return randomBytes(32).toString("hex");
41
+ return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("");
42
42
  }
43
43
 
44
44
  /**
@@ -0,0 +1,83 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>queue.validate command handler (RFC-1140) — validates a queue
4
+ manifest and reports derived per-item pipeline status plus the next
5
+ actionable item. Read-only; the orchestrator runs this before a queued batch.</purpose>
6
+ <non-goals>
7
+ <item>Do not execute the queue — this command validates and previews only.</item>
8
+ <item>Do not mutate any files.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-1140: initial queue.validate handler.</item>
13
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
14
+
15
+ Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import type {
20
+ ForgeCommandInput,
21
+ ForgeCommandResult,
22
+ ForgeRuntimeContext,
23
+ } from "../../../src/types.ts";
24
+ import { deriveQueueReport } from "../../../src/pipeline-status.ts";
25
+ import { loadQueueManifest } from "../manifest.ts";
26
+ import type { QueueValidateResult } from "../types.ts";
27
+
28
+ export async function runQueueValidate(
29
+ input: ForgeCommandInput,
30
+ context: ForgeRuntimeContext,
31
+ ): Promise<ForgeCommandResult<QueueValidateResult>> {
32
+ const { workspaceRoot, logger, outputFormat } = context;
33
+
34
+ const file = input.flags["file"] as string | undefined;
35
+ if (!file) {
36
+ throw new Error("queue.validate requires --file <path-to-manifest.yaml>");
37
+ }
38
+
39
+ const { manifest, errors, warnings } = await loadQueueManifest(workspaceRoot, file);
40
+
41
+ const report =
42
+ errors.length === 0
43
+ ? await deriveQueueReport(
44
+ workspaceRoot,
45
+ manifest.items.map((i) => i.id),
46
+ )
47
+ : { items: [], next: null };
48
+
49
+ const status = errors.length === 0 ? "pass" : "fail";
50
+
51
+ if (outputFormat === "pretty") {
52
+ logger.section(`Queue: ${manifest.id || file}`);
53
+ for (const item of report.items) {
54
+ const step = item.pipelineStep ? ` (${item.pipelineStep})` : "";
55
+ logger.info(` ${item.id}: ${item.status}${step}`);
56
+ }
57
+ logger.info(`next: ${report.next ?? "—"}`);
58
+ for (const w of warnings) {
59
+ logger.warn(` ${w.ruleId}: ${w.message}`);
60
+ }
61
+ for (const e of errors) {
62
+ logger.error(` ${e.ruleId}: ${e.message}`);
63
+ }
64
+ }
65
+
66
+ return {
67
+ data: {
68
+ command: "queue.validate",
69
+ status,
70
+ queue: manifest.id,
71
+ file,
72
+ items: report.items,
73
+ next: report.next,
74
+ errors,
75
+ warnings,
76
+ },
77
+ summary:
78
+ status === "pass"
79
+ ? `Queue "${manifest.id}": ${report.items.length} item(s), next: ${report.next ?? "none"}`
80
+ : `Queue manifest invalid: ${errors.length} error(s)`,
81
+ exitCode: status === "pass" ? 0 : 1,
82
+ };
83
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Barrel export for the forge queue module (RFC-1140).</purpose>
4
+ <non-goals>
5
+ <item>Do not add logic here — re-exports only.</item>
6
+ </non-goals>
7
+ </MODULE_CONTRACT>
8
+ <CHANGE_SUMMARY>
9
+ <item>RFC-1140: initial barrel export.</item>
10
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
11
+
12
+ Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
13
+ </CHANGE_SUMMARY>
14
+ */
15
+
16
+ export { createForgeQueueModule } from "./queue.module.ts";
17
+ export { loadQueueManifest } from "./manifest.ts";
18
+ export { queueManifestSchema, QUEUE_ITEM_ID_PATTERN } from "./types.ts";
19
+ export type { QueueManifest, QueueValidateResult } from "./types.ts";
@@ -0,0 +1,183 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Queue manifest loading and validation (RFC-1140) — YAML parse, zod
4
+ schema, id↔filename stem check, item id pattern, document resolution across
5
+ active and archive dirs, duplicate detection, and dependsOn order warnings.</purpose>
6
+ <non-goals>
7
+ <item>Do not derive item status — that is src/pipeline-status.ts.</item>
8
+ <item>Do not mutate the manifest file.</item>
9
+ </non-goals>
10
+ </MODULE_CONTRACT>
11
+ <CHANGE_SUMMARY>
12
+ <item>RFC-1140: initial manifest loader and validator.</item>
13
+ <item>RFC-1140: steps 1-4 — shared resolver, queue module, registration
14
+
15
+ Extract pipeline-status derivation into packages/forge/src/pipeline-status.ts, refactor rfc.pipeline.status onto it, add os/queue module with queue.validate command, register in WORKSHOP_MODULE_MAP.forge + bin/cli.ts + package.json exports.</item>
16
+ </CHANGE_SUMMARY>
17
+ */
18
+
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import YAML from "yaml";
22
+
23
+ import type { Diagnostic } from "../../src/types.ts";
24
+ import { resolveDocument } from "../../src/pipeline-status.ts";
25
+ import { QUEUE_ITEM_ID_PATTERN, queueManifestSchema } from "./types.ts";
26
+ import type { QueueManifest } from "./types.ts";
27
+
28
+ export interface LoadedQueueManifest {
29
+ manifest: QueueManifest;
30
+ errors: Diagnostic[];
31
+ warnings: Diagnostic[];
32
+ }
33
+
34
+ function diag(
35
+ ruleId: string,
36
+ severity: "error" | "warning",
37
+ message: string,
38
+ file?: string,
39
+ ): Diagnostic {
40
+ return { ruleId, severity, message, file };
41
+ }
42
+
43
+ /**
44
+ * Load and validate a queue manifest. Blocking errors: unreadable/invalid
45
+ * YAML, schema violations, id↔filename mismatch, malformed or unresolvable
46
+ * item ids, duplicates. Non-blocking: dependsOn order violations.
47
+ */
48
+ export async function loadQueueManifest(
49
+ workspaceRoot: string,
50
+ filePath: string,
51
+ ): Promise<LoadedQueueManifest> {
52
+ const errors: Diagnostic[] = [];
53
+ const warnings: Diagnostic[] = [];
54
+ const absolutePath = path.isAbsolute(filePath)
55
+ ? filePath
56
+ : path.join(workspaceRoot, filePath);
57
+ const displayPath = path.isAbsolute(filePath)
58
+ ? path.relative(workspaceRoot, filePath)
59
+ : filePath;
60
+
61
+ let source: string;
62
+ try {
63
+ source = await fs.readFile(absolutePath, "utf-8");
64
+ } catch (e) {
65
+ errors.push(
66
+ diag(
67
+ "QUEUE-01",
68
+ "error",
69
+ `Cannot read queue manifest: ${e instanceof Error ? e.message : String(e)}`,
70
+ displayPath,
71
+ ),
72
+ );
73
+ return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
74
+ }
75
+
76
+ let raw: unknown;
77
+ try {
78
+ raw = YAML.parse(source);
79
+ } catch (e) {
80
+ errors.push(
81
+ diag(
82
+ "QUEUE-01",
83
+ "error",
84
+ `YAML parse error: ${e instanceof Error ? e.message : String(e)}`,
85
+ displayPath,
86
+ ),
87
+ );
88
+ return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
89
+ }
90
+
91
+ const parsed = queueManifestSchema.safeParse(raw);
92
+ if (!parsed.success) {
93
+ for (const issue of parsed.error.issues) {
94
+ errors.push(
95
+ diag(
96
+ "QUEUE-01",
97
+ "error",
98
+ `Manifest schema violation at ${issue.path.join(".") || "<root>"}: ${issue.message}`,
99
+ displayPath,
100
+ ),
101
+ );
102
+ }
103
+ return { manifest: { id: "", createdAt: "", items: [] }, errors, warnings };
104
+ }
105
+
106
+ const manifest = parsed.data;
107
+
108
+ // QUEUE-02: manifest id must equal the filename stem.
109
+ const stem = path.basename(absolutePath).replace(/\.(ya?ml)$/i, "");
110
+ if (manifest.id !== stem) {
111
+ errors.push(
112
+ diag(
113
+ "QUEUE-02",
114
+ "error",
115
+ `Manifest id "${manifest.id}" does not match filename stem "${stem}"`,
116
+ displayPath,
117
+ ),
118
+ );
119
+ }
120
+
121
+ // QUEUE-03/04/05: item id pattern, resolution, duplicates.
122
+ const seen = new Set<string>();
123
+ const resolvedDependsOn = new Map<string, string[]>();
124
+ for (const item of manifest.items) {
125
+ const id = item.id.trim();
126
+ if (!QUEUE_ITEM_ID_PATTERN.test(id)) {
127
+ errors.push(
128
+ diag("QUEUE-03", "error", `Item id "${item.id}" does not match ^(RFC|ADR)-\\d{4}$`, displayPath),
129
+ );
130
+ continue;
131
+ }
132
+ const upper = id.toUpperCase();
133
+ if (seen.has(upper)) {
134
+ errors.push(diag("QUEUE-05", "error", `Duplicate item id "${upper}"`, displayPath));
135
+ continue;
136
+ }
137
+ seen.add(upper);
138
+
139
+ const doc = await resolveDocument(workspaceRoot, upper);
140
+ if (!doc) {
141
+ errors.push(
142
+ diag(
143
+ "QUEUE-04",
144
+ "error",
145
+ `Item "${upper}" does not resolve to a document in docs/rfcs/ or docs/adrs/ (incl. archive)`,
146
+ displayPath,
147
+ ),
148
+ );
149
+ continue;
150
+ }
151
+
152
+ const dependsOn = doc.frontmatter["dependsOn"];
153
+ if (Array.isArray(dependsOn)) {
154
+ resolvedDependsOn.set(
155
+ upper,
156
+ dependsOn.map((d) => String(d).toUpperCase()),
157
+ );
158
+ }
159
+ }
160
+
161
+ // QUEUE-06: dependsOn order — an item must not precede its in-queue dependency.
162
+ const order = new Map<string, number>();
163
+ manifest.items.forEach((item, index) => order.set(item.id.trim().toUpperCase(), index));
164
+ for (const [itemId, deps] of resolvedDependsOn) {
165
+ const itemIndex = order.get(itemId);
166
+ if (itemIndex === undefined) continue;
167
+ for (const dep of deps) {
168
+ const depIndex = order.get(dep);
169
+ if (depIndex !== undefined && depIndex > itemIndex) {
170
+ warnings.push(
171
+ diag(
172
+ "QUEUE-06",
173
+ "warning",
174
+ `Item "${itemId}" (position ${itemIndex + 1}) precedes its dependsOn dependency "${dep}" (position ${depIndex + 1})`,
175
+ displayPath,
176
+ ),
177
+ );
178
+ }
179
+ }
180
+ }
181
+
182
+ return { manifest, errors, warnings };
183
+ }