@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.
- package/AGENTS.md +3 -1
- package/bin/cli.ts +4 -0
- package/dist/bin/cli.js +4 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/os/adr/adr.module.js +1 -1
- package/dist/os/adr/adr.module.js.map +1 -1
- package/dist/os/compass/compass.module.js +2 -2
- package/dist/os/compass/compass.module.js.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
- package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
- package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
- package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
- package/dist/os/core/core.module.js +1 -1
- package/dist/os/core/core.module.js.map +1 -1
- package/dist/os/mission/handlers/archive.d.ts.map +1 -1
- package/dist/os/mission/handlers/archive.js +22 -6
- package/dist/os/mission/handlers/archive.js.map +1 -1
- package/dist/os/program/lease.js +1 -1
- package/dist/os/program/lease.js.map +1 -1
- package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
- package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
- package/dist/os/queue/handlers/queue-validate.js +62 -0
- package/dist/os/queue/handlers/queue-validate.js.map +1 -0
- package/dist/os/queue/index.d.ts +5 -0
- package/dist/os/queue/index.d.ts.map +1 -0
- package/dist/os/queue/index.js +18 -0
- package/dist/os/queue/index.js.map +1 -0
- package/dist/os/queue/manifest.d.ts +14 -0
- package/dist/os/queue/manifest.d.ts.map +1 -0
- package/dist/os/queue/manifest.js +110 -0
- package/dist/os/queue/manifest.js.map +1 -0
- package/dist/os/queue/queue.module.d.ts +3 -0
- package/dist/os/queue/queue.module.d.ts.map +1 -0
- package/dist/os/queue/queue.module.js +56 -0
- package/dist/os/queue/queue.module.js.map +1 -0
- package/dist/os/queue/types.d.ts +25 -0
- package/dist/os/queue/types.d.ts.map +1 -0
- package/dist/os/queue/types.js +28 -0
- package/dist/os/queue/types.js.map +1 -0
- package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
- package/dist/os/rfc/handlers/list-create.js +79 -32
- package/dist/os/rfc/handlers/list-create.js.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
- package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
- package/dist/os/rfc/handlers/pipeline-status.js +15 -57
- package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
- package/dist/os/rfc/rfc.module.js +1 -1
- package/dist/os/rfc/rfc.module.js.map +1 -1
- package/dist/os/spec/spec.module.d.ts.map +1 -1
- package/dist/os/spec/spec.module.js +2 -0
- package/dist/os/spec/spec.module.js.map +1 -1
- package/dist/src/knowledge/index.d.ts +1 -0
- package/dist/src/knowledge/index.d.ts.map +1 -1
- package/dist/src/knowledge/index.js +1 -0
- package/dist/src/knowledge/index.js.map +1 -1
- package/dist/src/knowledge/sync.d.ts +24 -0
- package/dist/src/knowledge/sync.d.ts.map +1 -0
- package/dist/src/knowledge/sync.js +127 -0
- package/dist/src/knowledge/sync.js.map +1 -0
- package/dist/src/onboarding/doctor.d.ts.map +1 -1
- package/dist/src/onboarding/doctor.js +7 -6
- package/dist/src/onboarding/doctor.js.map +1 -1
- package/dist/src/onboarding/init.d.ts.map +1 -1
- package/dist/src/onboarding/init.js +15 -8
- package/dist/src/onboarding/init.js.map +1 -1
- package/dist/src/onboarding/upgrade.d.ts.map +1 -1
- package/dist/src/onboarding/upgrade.js +42 -13
- package/dist/src/onboarding/upgrade.js.map +1 -1
- package/dist/src/pipeline-status.d.ts +62 -0
- package/dist/src/pipeline-status.d.ts.map +1 -0
- package/dist/src/pipeline-status.js +220 -0
- package/dist/src/pipeline-status.js.map +1 -0
- package/dist/src/types.d.ts +1 -2
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/fs-atomic.js +1 -1
- package/dist/src/utils/fs-atomic.js.map +1 -1
- package/os/adr/adr.module.ts +1 -1
- package/os/compass/compass.module.ts +2 -2
- package/os/compass/handlers/compass-audit-handler.ts +116 -8
- package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
- package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
- package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
- package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
- package/os/core/core.module.ts +1 -1
- package/os/mission/handlers/archive.test.ts +49 -0
- package/os/mission/handlers/archive.ts +21 -6
- package/os/program/lease.ts +1 -1
- package/os/queue/handlers/queue-validate.ts +83 -0
- package/os/queue/index.ts +19 -0
- package/os/queue/manifest.ts +183 -0
- package/os/queue/queue-validate.test.ts +177 -0
- package/os/queue/queue.module.ts +59 -0
- package/os/queue/types.ts +49 -0
- package/os/rfc/handlers/list-create.ts +83 -35
- package/os/rfc/handlers/pipeline-status.ts +24 -67
- package/os/rfc/handlers/validate-rules.test.ts +50 -0
- package/os/rfc/rfc-create-concurrent.test.ts +118 -0
- package/os/rfc/rfc-create-hint.test.ts +90 -0
- package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
- package/os/rfc/rfc.module.ts +1 -1
- package/os/spec/spec.module.ts +2 -0
- package/package.json +17 -1
- package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
- package/src/knowledge/__tests__/sync.test.ts +227 -0
- package/src/knowledge/index.ts +7 -0
- package/src/knowledge/sync.ts +161 -0
- package/src/onboarding/doctor.ts +7 -6
- package/src/onboarding/init.ts +15 -8
- package/src/onboarding/upgrade.ts +47 -16
- package/src/pipeline-status.test.ts +192 -0
- package/src/pipeline-status.ts +319 -0
- package/src/tests/upgrade.test.ts +53 -0
- package/src/types.ts +2 -2
- package/src/utils/fs-atomic.ts +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { runRfcCreate } from "./handlers/list-create.ts";
|
|
6
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
<MODULE_CONTRACT>
|
|
10
|
+
<purpose>
|
|
11
|
+
RFC-1138 AC-5: concurrent rfc.create invocations must produce distinct
|
|
12
|
+
RFC ids — the wx (exclusive-create) write + bounded retry on EEXIST
|
|
13
|
+
replaces the old scan-then-write race.
|
|
14
|
+
</purpose>
|
|
15
|
+
</MODULE_CONTRACT>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const RFC_TEMPLATE = `---
|
|
19
|
+
id: RFC-0000
|
|
20
|
+
title: "TEMPLATE"
|
|
21
|
+
kind: command
|
|
22
|
+
scope: workspace
|
|
23
|
+
createdAt: YYYY-MM-DD
|
|
24
|
+
updatedAt: YYYY-MM-DD
|
|
25
|
+
satisfies: []
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# RFC-0000: TEMPLATE
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function makeContext(workspaceRoot: string): ForgeRuntimeContext {
|
|
32
|
+
return {
|
|
33
|
+
workspaceRoot,
|
|
34
|
+
logger: {
|
|
35
|
+
info: () => {},
|
|
36
|
+
success: () => {},
|
|
37
|
+
warn: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
debug: () => {},
|
|
40
|
+
},
|
|
41
|
+
outputFormat: "json",
|
|
42
|
+
dryRun: false,
|
|
43
|
+
} as unknown as ForgeRuntimeContext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeInput(title: string): ForgeCommandInput {
|
|
47
|
+
return {
|
|
48
|
+
commandName: "rfc.create",
|
|
49
|
+
// kind "command" avoids the post-cutoff --satisfies requirement
|
|
50
|
+
// (only architecture|contract require DNA ids).
|
|
51
|
+
flags: { title, kind: "command", scope: "workspace" },
|
|
52
|
+
} as unknown as ForgeCommandInput;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("rfc.create — RFC-1138 concurrent id allocation", () => {
|
|
56
|
+
let tmpDir: string;
|
|
57
|
+
let rfcDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-create-"));
|
|
61
|
+
rfcDir = path.join(tmpDir, "docs", "rfcs");
|
|
62
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
63
|
+
await fs.writeFile(path.join(rfcDir, "rfc-0000-template.md"), RFC_TEMPLATE, "utf-8");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("AC-5: five concurrent creates produce five distinct ids", async () => {
|
|
71
|
+
const results = await Promise.all(
|
|
72
|
+
[1, 2, 3, 4, 5].map((n) =>
|
|
73
|
+
runRfcCreate(makeInput(`Concurrent RFC ${n}`), makeContext(tmpDir)),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const ids = results.map((r) => (r.data as { id: string }).id);
|
|
78
|
+
expect(new Set(ids).size).toBe(5);
|
|
79
|
+
|
|
80
|
+
const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
|
|
81
|
+
expect(files).toHaveLength(5);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("AC-5: sequential creates still allocate monotonically", async () => {
|
|
85
|
+
const first = await runRfcCreate(makeInput("First"), makeContext(tmpDir));
|
|
86
|
+
const second = await runRfcCreate(makeInput("Second"), makeContext(tmpDir));
|
|
87
|
+
|
|
88
|
+
const firstId = (first.data as { id: string }).id;
|
|
89
|
+
const secondId = (second.data as { id: string }).id;
|
|
90
|
+
expect(firstId).not.toBe(secondId);
|
|
91
|
+
expect(secondId > firstId).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("AC-5: pre-existing highest id is respected after a collision", async () => {
|
|
95
|
+
// Seed rfc-0001 so the first create takes 0002; a file racing in at 0002
|
|
96
|
+
// forces the retry path to land on 0003.
|
|
97
|
+
await fs.writeFile(path.join(rfcDir, "rfc-0001-seeded.md"), "---\nid: RFC-0001\n---\n", "utf-8");
|
|
98
|
+
const winner = runRfcCreate(makeInput("Winner"), makeContext(tmpDir));
|
|
99
|
+
// Interleave a manual claim at the id the scan will compute.
|
|
100
|
+
const claim = (async () => {
|
|
101
|
+
// Small delay so the create's scan completes first, then we steal 0002.
|
|
102
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
103
|
+
await fs
|
|
104
|
+
.writeFile(path.join(rfcDir, "rfc-0002-interloper.md"), "---\nid: RFC-0002\n---\n", {
|
|
105
|
+
flag: "wx",
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
})();
|
|
109
|
+
const [result] = await Promise.all([winner, claim]);
|
|
110
|
+
|
|
111
|
+
const id = (result.data as { id: string }).id;
|
|
112
|
+
// Either the create won 0002 (interloper lost the wx race) or it retried to 0003.
|
|
113
|
+
expect(["RFC-0002", "RFC-0003"]).toContain(id);
|
|
114
|
+
const files = (await fs.readdir(rfcDir)).filter((f) => f.startsWith("rfc-") && f !== "rfc-0000-template.md");
|
|
115
|
+
// Seeded + interloper (if it won) + create output — never a silent overwrite.
|
|
116
|
+
expect(files.length).toBeGreaterThanOrEqual(2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
RFC-1139 AC-1: the next-steps hint printed by rfc.create must be an
|
|
5
|
+
executable command — correct binary (werkstatt, not forge), correct
|
|
6
|
+
subcommand (run), and correct flags (--id, not --file).
|
|
7
|
+
</purpose>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { runRfcCreate } from "./handlers/list-create.ts";
|
|
16
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
17
|
+
|
|
18
|
+
const RFC_DIR = "docs/rfcs";
|
|
19
|
+
|
|
20
|
+
const TEMPLATE = `---
|
|
21
|
+
id: RFC-0000
|
|
22
|
+
title: "Template"
|
|
23
|
+
status: draft
|
|
24
|
+
kind: command
|
|
25
|
+
scope: workspace
|
|
26
|
+
createdAt: YYYY-MM-DD
|
|
27
|
+
updatedAt: YYYY-MM-DD
|
|
28
|
+
satisfies: []
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
# RFC-0000: Template
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function makeContext(root: string): ForgeRuntimeContext {
|
|
35
|
+
return {
|
|
36
|
+
workspaceRoot: root,
|
|
37
|
+
logger: {
|
|
38
|
+
info() {},
|
|
39
|
+
warn() {},
|
|
40
|
+
error() {},
|
|
41
|
+
success() {},
|
|
42
|
+
section() {},
|
|
43
|
+
getEvents: () => [],
|
|
44
|
+
},
|
|
45
|
+
outputFormat: "json",
|
|
46
|
+
dryRun: false,
|
|
47
|
+
} as unknown as ForgeRuntimeContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeInput(flags: Record<string, unknown>): ForgeCommandInput {
|
|
51
|
+
return { argv: [], flags } as unknown as ForgeCommandInput;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("rfc.create — RFC-1139 next-steps hint accuracy", () => {
|
|
55
|
+
let root: string;
|
|
56
|
+
let rfcDir: string;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-hint-"));
|
|
60
|
+
rfcDir = path.join(root, RFC_DIR);
|
|
61
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
62
|
+
await fs.writeFile(path.join(rfcDir, "rfc-0000-template.md"), TEMPLATE, "utf8");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("AC-1: next-steps hint is an executable werkstatt command with --id", async () => {
|
|
70
|
+
const result = await runRfcCreate(
|
|
71
|
+
makeInput({ title: "Hint Test", kind: "command", scope: "workspace" }),
|
|
72
|
+
makeContext(root),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const steps = result.nextSteps ?? [];
|
|
76
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
77
|
+
|
|
78
|
+
for (const step of steps) {
|
|
79
|
+
const match = step.action.match(/pnpm exec (\S+) run ([\w.]+)/);
|
|
80
|
+
expect(match, `hint "${step.action}" must contain a runnable command`).not.toBeNull();
|
|
81
|
+
const [, binary, command] = match!;
|
|
82
|
+
// forge has no `run` subcommand — werkstatt does
|
|
83
|
+
expect(binary).toBe("werkstatt");
|
|
84
|
+
expect(command).toBe("rfc.validate");
|
|
85
|
+
// rfc.validate takes --id, not --file
|
|
86
|
+
expect(step.action).toContain("--id RFC-");
|
|
87
|
+
expect(step.action).not.toContain("--file");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
RFC-1139 AC-4: read-only commands must not write to the working tree —
|
|
5
|
+
no ambient regeneration of tracked generated files as a side effect.
|
|
6
|
+
Guards against future read-path writers.
|
|
7
|
+
</purpose>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { runRfcList } from "./handlers/list-create.ts";
|
|
16
|
+
import type { ForgeCommandInput, ForgeRuntimeContext } from "../../src/types.ts";
|
|
17
|
+
|
|
18
|
+
function makeContext(root: string): ForgeRuntimeContext {
|
|
19
|
+
return {
|
|
20
|
+
workspaceRoot: root,
|
|
21
|
+
logger: {
|
|
22
|
+
info() {},
|
|
23
|
+
warn() {},
|
|
24
|
+
error() {},
|
|
25
|
+
success() {},
|
|
26
|
+
section() {},
|
|
27
|
+
getEvents: () => [],
|
|
28
|
+
},
|
|
29
|
+
outputFormat: "json",
|
|
30
|
+
dryRun: false,
|
|
31
|
+
} as unknown as ForgeRuntimeContext;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
|
|
35
|
+
return { argv: [], flags } as unknown as ForgeCommandInput;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function snapshotTree(root: string): Promise<Map<string, string>> {
|
|
39
|
+
const snapshot = new Map<string, string>();
|
|
40
|
+
async function walk(dir: string): Promise<void> {
|
|
41
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
42
|
+
const full = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
await walk(full);
|
|
45
|
+
} else if (entry.isFile()) {
|
|
46
|
+
snapshot.set(path.relative(root, full), await fs.readFile(full, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await walk(root);
|
|
51
|
+
return snapshot;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("read-only commands — RFC-1139 no side-effect writes", () => {
|
|
55
|
+
let root: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "rfc-readonly-"));
|
|
59
|
+
const rfcDir = path.join(root, "docs", "rfcs");
|
|
60
|
+
await fs.mkdir(rfcDir, { recursive: true });
|
|
61
|
+
await fs.writeFile(
|
|
62
|
+
path.join(rfcDir, "rfc-0001-test.md"),
|
|
63
|
+
`---\nid: RFC-0001\ntitle: "Test"\nstatus: draft\n---\n\n# RFC-0001\n`,
|
|
64
|
+
"utf8",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("AC-4: rfc.list leaves the working tree byte-identical", async () => {
|
|
73
|
+
const before = await snapshotTree(root);
|
|
74
|
+
const result = await runRfcList(makeInput(), makeContext(root));
|
|
75
|
+
expect(result.data?.status).toBe("ok");
|
|
76
|
+
const after = await snapshotTree(root);
|
|
77
|
+
|
|
78
|
+
expect([...after.keys()].sort()).toEqual([...before.keys()].sort());
|
|
79
|
+
for (const [file, content] of before) {
|
|
80
|
+
expect(after.get(file), `${file} modified by read-only command`).toBe(content);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
package/os/rfc/rfc.module.ts
CHANGED
|
@@ -396,7 +396,7 @@ const {
|
|
|
396
396
|
"RFC-0476: the exclusive atomic path for accepted → implemented transitions. " +
|
|
397
397
|
"Verifies preconditions (accepted status, checked+evidenced criteria, clean tree, " +
|
|
398
398
|
"reachable RFC-referencing commit, passing probe evidence) then atomically sets " +
|
|
399
|
-
"status: implemented, implementedAt, and updatedAt. Use --dry-run to preview.",
|
|
399
|
+
"status: implemented, implementedAt, and updatedAt. Use --dry-run to preview. Required flags: `--id`.",
|
|
400
400
|
scope: "workspace",
|
|
401
401
|
mutatesState: true,
|
|
402
402
|
writes: ["docs/rfcs/**/*.md"],
|
package/os/spec/spec.module.ts
CHANGED
|
@@ -103,6 +103,7 @@ const { runSpecValidate } = await import("./spec-validate.ts");
|
|
|
103
103
|
"List all living feature specs in docs/specs/live/. " +
|
|
104
104
|
"Returns domain, title, lastMergedRfc, updatedAt, and historyCount for each spec.",
|
|
105
105
|
scope: "workspace",
|
|
106
|
+
flags: {},
|
|
106
107
|
reads: ["docs/specs/live/*.md"],
|
|
107
108
|
execute: runSpecLiveList,
|
|
108
109
|
},
|
|
@@ -128,6 +129,7 @@ const { runSpecValidate } = await import("./spec-validate.ts");
|
|
|
128
129
|
"V-LS-03 (lastMergedRfc is archived), V-LS-04 (history entries are archived), " +
|
|
129
130
|
"V-LS-05 (no duplicate domains).",
|
|
130
131
|
scope: "workspace",
|
|
132
|
+
flags: {},
|
|
131
133
|
reads: ["docs/specs/live/*.md", "docs/rfcs/**/*.md"],
|
|
132
134
|
execute: runSpecLiveValidate,
|
|
133
135
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpgogol/forge",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -147,6 +147,14 @@
|
|
|
147
147
|
"./os/plugin": {
|
|
148
148
|
"types": "./os/plugin/plugin.module.ts",
|
|
149
149
|
"default": "./os/plugin/plugin.module.ts"
|
|
150
|
+
},
|
|
151
|
+
"./os/queue": {
|
|
152
|
+
"types": "./os/queue/index.ts",
|
|
153
|
+
"default": "./os/queue/index.ts"
|
|
154
|
+
},
|
|
155
|
+
"./os/queue-module": {
|
|
156
|
+
"types": "./os/queue/queue.module.ts",
|
|
157
|
+
"default": "./os/queue/queue.module.ts"
|
|
150
158
|
}
|
|
151
159
|
},
|
|
152
160
|
"files": [
|
|
@@ -324,6 +332,14 @@
|
|
|
324
332
|
"./os/plugin": {
|
|
325
333
|
"types": "./dist/os/plugin/plugin.module.d.ts",
|
|
326
334
|
"default": "./dist/os/plugin/plugin.module.js"
|
|
335
|
+
},
|
|
336
|
+
"./os/queue": {
|
|
337
|
+
"types": "./dist/os/queue/index.d.ts",
|
|
338
|
+
"default": "./dist/os/queue/index.js"
|
|
339
|
+
},
|
|
340
|
+
"./os/queue-module": {
|
|
341
|
+
"types": "./dist/os/queue/queue.module.d.ts",
|
|
342
|
+
"default": "./dist/os/queue/queue.module.js"
|
|
327
343
|
}
|
|
328
344
|
}
|
|
329
345
|
},
|
|
@@ -8,6 +8,7 @@ dependsOn: ['my-preferences']
|
|
|
8
8
|
languagePolicy: ref(PREFERENCES.md)
|
|
9
9
|
triggers: ["I just want to see the result", "run the full pipeline automatically", "implement this end-to-end without pauses"]
|
|
10
10
|
---
|
|
11
|
+
|
|
11
12
|
<!--
|
|
12
13
|
<MODULE_CONTRACT>
|
|
13
14
|
<purpose>fo-idea-i-just-want-to-see-the-result skill — Orchestrate the full feature pipeline (idea, audit, enhance, plan, implement, review, fix) in one invocation. Accepts a raw idea or RFC/ADR id. Use when the operator wants the complete pipeline.</purpose>
|
|
@@ -19,10 +20,15 @@ triggers: ["I just want to see the result", "run the full pipeline automatically
|
|
|
19
20
|
<item>RFC-1097: sweep — SKILL.md headers + classification fixes
|
|
20
21
|
|
|
21
22
|
Sweep batch 1: add Compass v2 headers to 45 SKILL.md files (purpose derived from frontmatter description). Fix non-skill-markdown exclusion to check filename not workspace-relative path (packages/AGENTS.md escaped it). Add .coverage to ignoredDirs.</item>
|
|
23
|
+
<item>RFC-1140: step 5 — queue-mode section in orchestrator SKILL.md
|
|
24
|
+
|
|
25
|
+
Add Queue mode section (pre-flight queue.validate, loop semantics, failure handling, manifest immutability) to fo-idea-i-just-want-to-see-the-result SKILL.md, sync .agents copy, add forgeQueueModule row to forge AGENTS.md.</item>
|
|
26
|
+
<item>RFC-1140: queue mode — materialize manifest at invocation from pasted doc list
|
|
27
|
+
|
|
28
|
+
When the invocation carries >=2 RFC/ADR ids without a manifest path, the orchestrator builds docs/queues/session-<timestamp>.yaml from the pasted order, runs queue.validate, then processes queue mode. Unresolvable ids are named explicitly.</item>
|
|
22
29
|
</CHANGE_SUMMARY>
|
|
23
30
|
-->
|
|
24
31
|
|
|
25
|
-
|
|
26
32
|
# Full Pipeline — Just Want to See the Result
|
|
27
33
|
|
|
28
34
|
Before starting, read `PREFERENCES.md` at the repository root. If the file is missing or `aiLanguage` is unset, ask the operator once and create the file using the `my-preferences` skill semantics.
|
|
@@ -48,8 +54,47 @@ The operator may provide either:
|
|
|
48
54
|
|
|
49
55
|
- **A raw idea** — natural-language description of a feature, change, or decision. The skill will invoke `fo-idea` as step 0 to create the RFC/ADR first.
|
|
50
56
|
- **An existing RFC/ADR id** — e.g. `RFC-XXXX` or `ADR-XXXX`. The skill skips idea creation and starts the pipeline from the appropriate step.
|
|
57
|
+
- **A queue manifest** — `--queue <path>` or a `docs/queues/*.yaml` path in the invocation text. The skill enters queue mode (see §Queue mode) and processes the manifest's `items[]` in order.
|
|
58
|
+
- **A pasted document list** — >=2 `RFC-XXXX`/`ADR-XXXX` ids in the invocation text (e.g. another agent's ordered implementation plan). The skill materializes a session manifest (see §Queue mode → Manifest materialization) and enters queue mode.
|
|
51
59
|
- **Nothing** — if neither is provided, check session context and IDE for a recently created document. If none found, ask the operator: "Какую идею реализуем? Опишите идею или укажите RFC-XXXX / ADR-XXXX."
|
|
52
60
|
|
|
61
|
+
## Queue mode
|
|
62
|
+
|
|
63
|
+
The orchestrator runs in **queue mode** when the invocation carries `--queue <path>`, a `docs/queues/*.yaml` path, OR a pasted document list (>=2 `RFC-XXXX`/`ADR-XXXX` ids — e.g. another agent's ordered implementation plan).
|
|
64
|
+
|
|
65
|
+
**Manifest materialization (mandatory when no manifest path is given):** Build the manifest from the invocation text before anything else:
|
|
66
|
+
|
|
67
|
+
1. Extract `RFC-\d{4}`/`ADR-\d{4}` tokens in order of appearance; dedupe preserving first-seen order. The pasted order IS the dependency order — preserve it even when the surrounding prose discusses a different grouping.
|
|
68
|
+
2. Write `docs/queues/session-<YYYYMMDD>-<HHmm>.yaml`:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
id: session-<YYYYMMDD>-<HHmm>
|
|
72
|
+
createdAt: <YYYY-MM-DD>
|
|
73
|
+
items:
|
|
74
|
+
- id: RFC-XXXX
|
|
75
|
+
- id: ADR-XXXX
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The `id` field MUST equal the filename stem (`queue.validate` enforces this). Leave the file uncommitted — it is a session artifact; the operator may commit it for cross-session durability.
|
|
79
|
+
|
|
80
|
+
3. If the paste mixes ids with prose, ignore the prose — only the id sequence matters. Do not invent items that are not in the text.
|
|
81
|
+
|
|
82
|
+
**Pre-flight (mandatory):** Run `pnpm exec werkstatt run queue.validate --file <manifest> --json`. If the command exits non-zero (schema errors, unknown ids, duplicates, id↔filename mismatch), do NOT start the batch — report the errors and stop. When a pasted list references ids that do not resolve (QUEUE-04), name them explicitly — the operator must create those documents first or fix the list.
|
|
83
|
+
|
|
84
|
+
**Batch plan preview:** Emit the existing batch plan preview per `_shared/fo-pipeline-conventions.md` §Batch plan preview, listing items in manifest order.
|
|
85
|
+
|
|
86
|
+
**Loop semantics:** Process `items[]` in manifest order with no pauses between items:
|
|
87
|
+
|
|
88
|
+
1. **Skip terminal items** — items whose derived status is `implemented` or `skipped` (rejected/superseded frontmatter) are recorded in the batch summary and skipped.
|
|
89
|
+
2. **Resume mid-pipeline items** — an `in-progress` item resumes at its derived `pipelineStep`, not from step 1.
|
|
90
|
+
3. **Run the existing per-doc pipeline** — RFC items run audit → enhance → plan → implement; ADR items run implement only. All skill-internal interactions (grilling) stay inside the invoked skills.
|
|
91
|
+
4. **Batch-item checkpoint** — after each item, emit the context checkpoint per `_shared/fo-pipeline-conventions.md` §Context checkpoint between batch items.
|
|
92
|
+
5. **Clean-tree gate** — before starting the next item, verify the working tree has no uncommitted leftovers from the completed item; warn and stop if dirty.
|
|
93
|
+
|
|
94
|
+
**Failure:** If an item fails after its error checkpoint, stop the batch. The report names the blocked item id and the remaining item count. `blocked` is in-session report language only — never persist it into frontmatter, manifests, or files. Resume = re-invoke with the same manifest; `queue.validate` derives where to continue.
|
|
95
|
+
|
|
96
|
+
**Manifest immutability:** Do not edit `items[]` or the manifest during a queue run. Reordering requires stopping the batch and re-validating.
|
|
97
|
+
|
|
53
98
|
## Process
|
|
54
99
|
|
|
55
100
|
### 0. Idea creation (conditional)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Unit tests for the append-only knowledge sync (syncKnowledgeFile / planKnowledgeSync) — covers copy, merge by entry-ID union, conflict handling, skip semantics, and directory recursion.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>2026-09-24: initial tests for append-only knowledge sync.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { planKnowledgeSync, syncKnowledgeFile } from "../sync.ts";
|
|
15
|
+
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tempDir = mkdtempSync(join(tmpdir(), "forge-knowledge-sync-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function entry(id: string, title: string, body: string, layer = "L0"): string {
|
|
27
|
+
const confirmations =
|
|
28
|
+
layer === "L2" ? `lastConfirmedAt: 2026-08-03\nconfirmations: 1\n` : "";
|
|
29
|
+
return `### ${id}: ${title}\n\n\`\`\`knowledge-entry\nid: ${id}\nlayer: ${layer}\ncreated: 2026-08-03\n${confirmations}status: active\n\`\`\`\n\n${body}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function knowledgeFile(layer: string, entries: string[]): string {
|
|
33
|
+
return `<!-- knowledge-layer: ${layer} -->\n\n# Knowledge (${layer})\n\n${entries.join("\n")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("copies file when destination does not exist", () => {
|
|
37
|
+
const src = join(tempDir, "src", "qa-log.md");
|
|
38
|
+
const dest = join(tempDir, "dest", "qa-log.md");
|
|
39
|
+
mkdirSync(join(tempDir, "src"), { recursive: true });
|
|
40
|
+
const content = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
|
|
41
|
+
writeFileSync(src, content, "utf8");
|
|
42
|
+
|
|
43
|
+
const result = syncKnowledgeFile(src, dest);
|
|
44
|
+
|
|
45
|
+
expect(result.action).toBe("copied");
|
|
46
|
+
expect(readFileSync(dest, "utf8")).toBe(content);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unchanged when contents are identical", () => {
|
|
50
|
+
const src = join(tempDir, "qa-log.md");
|
|
51
|
+
const dest = join(tempDir, "dest.md");
|
|
52
|
+
const content = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
|
|
53
|
+
writeFileSync(src, content, "utf8");
|
|
54
|
+
writeFileSync(dest, content, "utf8");
|
|
55
|
+
|
|
56
|
+
const result = syncKnowledgeFile(src, dest);
|
|
57
|
+
|
|
58
|
+
expect(result.action).toBe("unchanged");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("merges: local entries preserved, new source entries appended", () => {
|
|
62
|
+
const src = join(tempDir, "qa-log.md");
|
|
63
|
+
const dest = join(tempDir, "dest.md");
|
|
64
|
+
writeFileSync(
|
|
65
|
+
src,
|
|
66
|
+
knowledgeFile("L0", [
|
|
67
|
+
entry("K-0001", "First", "Package body."),
|
|
68
|
+
entry("K-0002", "Second", "New package entry."),
|
|
69
|
+
]),
|
|
70
|
+
"utf8",
|
|
71
|
+
);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
dest,
|
|
74
|
+
knowledgeFile("L0", [
|
|
75
|
+
entry("K-0001", "First", "Locally edited body."),
|
|
76
|
+
entry("K-0057", "Local accumulation", "Project-specific record."),
|
|
77
|
+
]),
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = syncKnowledgeFile(src, dest);
|
|
82
|
+
|
|
83
|
+
expect(result.action).toBe("merged");
|
|
84
|
+
expect(result.appended).toEqual(["K-0002"]);
|
|
85
|
+
expect(result.conflicts).toEqual(["K-0001"]);
|
|
86
|
+
|
|
87
|
+
const merged = readFileSync(dest, "utf8");
|
|
88
|
+
// Local version of the conflicting entry wins
|
|
89
|
+
expect(merged).toContain("Locally edited body.");
|
|
90
|
+
expect(merged).not.toContain("Package body.");
|
|
91
|
+
// Local accumulated entry preserved
|
|
92
|
+
expect(merged).toContain("K-0057");
|
|
93
|
+
expect(merged).toContain("Project-specific record.");
|
|
94
|
+
// New source entry appended
|
|
95
|
+
expect(merged).toContain("K-0002");
|
|
96
|
+
expect(merged).toContain("New package entry.");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("merge into empty template appends all source entries", () => {
|
|
100
|
+
const src = join(tempDir, "qa-log.md");
|
|
101
|
+
const dest = join(tempDir, "dest.md");
|
|
102
|
+
writeFileSync(
|
|
103
|
+
src,
|
|
104
|
+
knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]),
|
|
105
|
+
"utf8",
|
|
106
|
+
);
|
|
107
|
+
// Destination is the shipped empty template (layer marker, no entries)
|
|
108
|
+
writeFileSync(dest, `<!-- knowledge-layer: L0 -->\n\n# Q&A Log (L0)\n`, "utf8");
|
|
109
|
+
|
|
110
|
+
const result = syncKnowledgeFile(src, dest);
|
|
111
|
+
|
|
112
|
+
expect(result.action).toBe("merged");
|
|
113
|
+
expect(result.appended).toEqual(["K-0001"]);
|
|
114
|
+
expect(readFileSync(dest, "utf8")).toContain("K-0001");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("unchanged when source has no new entries (conflicts only)", () => {
|
|
118
|
+
const src = join(tempDir, "qa-log.md");
|
|
119
|
+
const dest = join(tempDir, "dest.md");
|
|
120
|
+
writeFileSync(src, knowledgeFile("L0", [entry("K-0001", "First", "Package body.")]), "utf8");
|
|
121
|
+
writeFileSync(
|
|
122
|
+
dest,
|
|
123
|
+
knowledgeFile("L0", [
|
|
124
|
+
entry("K-0001", "First", "Locally edited body."),
|
|
125
|
+
entry("K-0002", "Local extra", "Local only."),
|
|
126
|
+
]),
|
|
127
|
+
"utf8",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const result = syncKnowledgeFile(src, dest);
|
|
131
|
+
|
|
132
|
+
expect(result.action).toBe("unchanged");
|
|
133
|
+
expect(result.conflicts).toEqual(["K-0001"]);
|
|
134
|
+
// File untouched — local content preserved byte-for-byte
|
|
135
|
+
expect(readFileSync(dest, "utf8")).toContain("Locally edited body.");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("skips divergent non-cumulative destination (knowledge-adjacent)", () => {
|
|
139
|
+
const src = join(tempDir, "forge-about.md");
|
|
140
|
+
const dest = join(tempDir, "dest.md");
|
|
141
|
+
writeFileSync(src, "# Forge\n\nNew package template text.\n", "utf8");
|
|
142
|
+
const localContent = "# Forge\n\nLocally filled-in project description.\n";
|
|
143
|
+
writeFileSync(dest, localContent, "utf8");
|
|
144
|
+
|
|
145
|
+
const result = syncKnowledgeFile(src, dest);
|
|
146
|
+
|
|
147
|
+
expect(result.action).toBe("skipped");
|
|
148
|
+
expect(readFileSync(dest, "utf8")).toBe(localContent);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("skips when destination is cumulative but source is not", () => {
|
|
152
|
+
const src = join(tempDir, "notes.md");
|
|
153
|
+
const dest = join(tempDir, "dest.md");
|
|
154
|
+
writeFileSync(src, "# Plain notes\n\nPackage rewrite.\n", "utf8");
|
|
155
|
+
const localContent = knowledgeFile("L0", [entry("K-0001", "First", "Local body.")]);
|
|
156
|
+
writeFileSync(dest, localContent, "utf8");
|
|
157
|
+
|
|
158
|
+
const result = syncKnowledgeFile(src, dest);
|
|
159
|
+
|
|
160
|
+
expect(result.action).toBe("skipped");
|
|
161
|
+
expect(readFileSync(dest, "utf8")).toBe(localContent);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("recurses into declared directories", () => {
|
|
165
|
+
const srcDir = join(tempDir, "gallery");
|
|
166
|
+
const destDir = join(tempDir, "dest-gallery");
|
|
167
|
+
mkdirSync(srcDir, { recursive: true });
|
|
168
|
+
writeFileSync(join(srcDir, "a.md"), "A package\n", "utf8");
|
|
169
|
+
writeFileSync(join(srcDir, "b.md"), "B package\n", "utf8");
|
|
170
|
+
mkdirSync(destDir, { recursive: true });
|
|
171
|
+
writeFileSync(join(destDir, "b.md"), "B local edits\n", "utf8");
|
|
172
|
+
|
|
173
|
+
const result = syncKnowledgeFile(srcDir, destDir);
|
|
174
|
+
|
|
175
|
+
expect(result.action).toBe("copied");
|
|
176
|
+
expect(readFileSync(join(destDir, "a.md"), "utf8")).toBe("A package\n");
|
|
177
|
+
// Existing local file preserved (non-cumulative → skipped)
|
|
178
|
+
expect(readFileSync(join(destDir, "b.md"), "utf8")).toBe("B local edits\n");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("planKnowledgeSync reports merged without writing", () => {
|
|
182
|
+
const src = join(tempDir, "qa-log.md");
|
|
183
|
+
const dest = join(tempDir, "dest.md");
|
|
184
|
+
writeFileSync(
|
|
185
|
+
src,
|
|
186
|
+
knowledgeFile("L0", [
|
|
187
|
+
entry("K-0001", "First", "Body one."),
|
|
188
|
+
entry("K-0002", "Second", "Body two."),
|
|
189
|
+
]),
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
const localContent = knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]);
|
|
193
|
+
writeFileSync(dest, localContent, "utf8");
|
|
194
|
+
|
|
195
|
+
const plan = planKnowledgeSync(src, dest);
|
|
196
|
+
|
|
197
|
+
expect(plan.action).toBe("merged");
|
|
198
|
+
expect(plan.appended).toEqual(["K-0002"]);
|
|
199
|
+
expect(plan.content).not.toBeNull();
|
|
200
|
+
// No write happened
|
|
201
|
+
expect(readFileSync(dest, "utf8")).toBe(localContent);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("planKnowledgeSync reports unchanged for locally accumulated file", () => {
|
|
205
|
+
const src = join(tempDir, "qa-log.md");
|
|
206
|
+
const dest = join(tempDir, "dest.md");
|
|
207
|
+
writeFileSync(src, knowledgeFile("L0", [entry("K-0001", "First", "Body one.")]), "utf8");
|
|
208
|
+
writeFileSync(
|
|
209
|
+
dest,
|
|
210
|
+
knowledgeFile("L0", [
|
|
211
|
+
entry("K-0001", "First", "Body one."),
|
|
212
|
+
entry("K-0002", "Local", "Accumulated locally."),
|
|
213
|
+
]),
|
|
214
|
+
"utf8",
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const plan = planKnowledgeSync(src, dest);
|
|
218
|
+
|
|
219
|
+
// Local superset — nothing to merge, not stale
|
|
220
|
+
expect(plan.action).toBe("unchanged");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("missing source returns unchanged", () => {
|
|
224
|
+
const result = syncKnowledgeFile(join(tempDir, "nope.md"), join(tempDir, "dest.md"));
|
|
225
|
+
expect(result.action).toBe("unchanged");
|
|
226
|
+
expect(existsSync(join(tempDir, "dest.md"))).toBe(false);
|
|
227
|
+
});
|
package/src/knowledge/index.ts
CHANGED