@cassiomc1/forgeloop 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor/rules/project-loop.mdc +3 -2
- package/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +2 -1
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +36 -0
- package/ENG/design-code-eng.md +31 -0
- package/ENG/documentation-quality-eng.md +400 -0
- package/EXECUTION_STATE.md +23 -13
- package/GUIDE_ROUTER.md +23 -8
- package/LOOP_ENGINEERING.md +77 -12
- package/PROTOCOL_INTEGRATION.md +9 -6
- package/README.md +82 -39
- package/docs/ARTIFACT_REFERENCE.md +442 -0
- package/docs/CLI_REFERENCE.md +892 -0
- package/docs/CROSS_HARNESS_CONTINUITY.md +198 -0
- package/docs/DOCUMENTATION_GUIDE.md +161 -0
- package/docs/GETTING_STARTED.md +348 -0
- package/docs/RECIPES.md +250 -0
- package/docs/TROUBLESHOOTING.md +345 -0
- package/docs/assets/forgeloop-flow.svg +1 -1
- package/docs/forgeloop-flow.mmd +1 -1
- package/package.json +13 -2
- package/schemas/task-descriptor.schema.json +56 -0
- package/schemas/work-state.schema.json +18 -1
- package/scripts/CI_VALIDATORS.md +7 -0
- package/src/cli.js +280 -388
- package/src/commands/advance.js +5 -2
- package/src/commands/audit.js +11 -1
- package/src/commands/clear-continuity.js +5 -2
- package/src/commands/clear-state.js +5 -2
- package/src/commands/complete.js +9 -1
- package/src/commands/continuity.js +5 -2
- package/src/commands/inspect.js +10 -2
- package/src/commands/next.js +5 -2
- package/src/commands/preflight.js +9 -1
- package/src/commands/prepare-completion.js +5 -2
- package/src/commands/reconcile-continuity.js +5 -2
- package/src/commands/record-check.js +7 -1
- package/src/commands/record-continuity.js +21 -14
- package/src/commands/record-terminal-result.js +7 -1
- package/src/commands/route.js +22 -18
- package/src/commands/run-check.js +52 -44
- package/src/commands/status.js +18 -12
- package/src/commands/task-create.js +94 -0
- package/src/commands/task-list.js +48 -0
- package/src/commands/task-migrate.js +34 -0
- package/src/commands/task-scope.js +75 -0
- package/src/commands/task-show.js +81 -0
- package/src/commands/task-unlock.js +35 -0
- package/src/commands/validate-protocol.js +37 -20
- package/src/commands/validate-state.js +24 -18
- package/src/config/guides.json +42 -0
- package/src/core/activation.js +8 -4
- package/src/core/artifact-registry.js +166 -0
- package/src/core/audit.js +65 -12
- package/src/core/bundles.js +76 -50
- package/src/core/cli-command-definitions.js +611 -0
- package/src/core/cli-metadata.js +23 -0
- package/src/core/completion-artifacts.js +161 -74
- package/src/core/completion.js +134 -76
- package/src/core/continuity.js +20 -13
- package/src/core/contract.js +6 -3
- package/src/core/error-codes.js +197 -0
- package/src/core/events.js +19 -14
- package/src/core/execution.js +38 -6
- package/src/core/gate-artifact.js +12 -9
- package/src/core/gates.js +4 -2
- package/src/core/guide-metadata.js +7 -11
- package/src/core/guide-registry.js +29 -0
- package/src/core/inspect.js +7 -4
- package/src/core/native-adapters.js +6 -0
- package/src/core/phase.js +85 -33
- package/src/core/preflight-consistency.js +24 -14
- package/src/core/preflight-loaders.js +16 -11
- package/src/core/preflight.js +44 -25
- package/src/core/protocol.js +2 -11
- package/src/core/receipt.js +1 -1
- package/src/core/report.js +2 -2
- package/src/core/repository.js +46 -12
- package/src/core/resumability.js +6 -4
- package/src/core/route-artifact.js +9 -5
- package/src/core/router.js +11 -7
- package/src/core/schema-validation.js +1 -0
- package/src/core/task-command.js +41 -0
- package/src/core/task-context.js +126 -0
- package/src/core/task-descriptor.js +81 -0
- package/src/core/task-discovery.js +116 -0
- package/src/core/task-identity.js +76 -0
- package/src/core/task-lock.js +209 -0
- package/src/core/task-migration-validation.js +140 -0
- package/src/core/task-migration.js +361 -0
- package/src/core/task-paths.js +96 -0
- package/src/core/task-scope.js +179 -0
- package/src/core/templates.js +3 -9
- package/src/core/work-state.js +24 -13
package/src/core/repository.js
CHANGED
|
@@ -18,19 +18,53 @@ export async function currentRepositoryFingerprint(target) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export
|
|
21
|
+
export function parsePorcelainV1Z(stdout) {
|
|
22
|
+
if (!stdout) return [];
|
|
23
|
+
const entries = [];
|
|
24
|
+
const tokens = stdout.split("\0");
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < tokens.length) {
|
|
27
|
+
const item = tokens[i];
|
|
28
|
+
if (!item) {
|
|
29
|
+
i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const status = item.slice(0, 2);
|
|
33
|
+
const filePath = item.slice(3);
|
|
34
|
+
if (status.startsWith("R") || status.startsWith("C") || status.includes("R") || status.includes("C")) {
|
|
35
|
+
// In porcelain -z, rename/copy formats have the previous path in next token
|
|
36
|
+
i++;
|
|
37
|
+
const nextToken = tokens[i];
|
|
38
|
+
if (nextToken) {
|
|
39
|
+
entries.push(nextToken);
|
|
40
|
+
}
|
|
41
|
+
entries.push(filePath);
|
|
42
|
+
} else {
|
|
43
|
+
entries.push(filePath);
|
|
44
|
+
}
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return [...new Set(
|
|
49
|
+
entries
|
|
50
|
+
.map((p) => p.replaceAll("\\", "/").replace(/^\.\//, "").trim())
|
|
51
|
+
.filter((relativePath) => relativePath && !relativePath.startsWith(".forgeloop/")),
|
|
52
|
+
)].sort((a, b) => a.localeCompare(b));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function currentChangedPaths(target, { paths = null } = {}) {
|
|
22
56
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
const args = ["-C", target];
|
|
58
|
+
if (paths && paths.length > 0) {
|
|
59
|
+
args.unshift("--literal-pathspecs");
|
|
60
|
+
}
|
|
61
|
+
args.push("status", "--porcelain=v1", "-z", "--untracked-files=all");
|
|
62
|
+
if (paths && paths.length > 0) {
|
|
63
|
+
args.push("--", ...paths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { stdout } = await execFileAsync("git", args, { windowsHide: true });
|
|
67
|
+
return parsePorcelainV1Z(stdout);
|
|
34
68
|
} catch {
|
|
35
69
|
return null;
|
|
36
70
|
}
|
package/src/core/resumability.js
CHANGED
|
@@ -3,9 +3,9 @@ import { createWorkState, readWorkState, writeWorkState } from "./work-state.js"
|
|
|
3
3
|
|
|
4
4
|
const DEFAULT_PENDING_STEPS = ["planning", "implementation", "verification"];
|
|
5
5
|
|
|
6
|
-
export async function ensureResumableState({ target, packageRoot, contract, route }) {
|
|
6
|
+
export async function ensureResumableState({ target, packageRoot, contract, route, taskId, statePath }) {
|
|
7
7
|
if (!contract || !route) return null;
|
|
8
|
-
const existing = await readWorkState(target, packageRoot);
|
|
8
|
+
const existing = await readWorkState(target, { packageRoot, taskId, statePath });
|
|
9
9
|
if (existing) return existing;
|
|
10
10
|
|
|
11
11
|
const state = createWorkState({
|
|
@@ -22,7 +22,7 @@ export async function ensureResumableState({ target, packageRoot, contract, rout
|
|
|
22
22
|
blockers: [],
|
|
23
23
|
verificationEvidence: [],
|
|
24
24
|
});
|
|
25
|
-
await writeWorkState(target, state, { packageRoot });
|
|
25
|
+
await writeWorkState(target, state, { packageRoot, taskId, statePath });
|
|
26
26
|
return state;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -35,6 +35,8 @@ export async function synchronizePreflightState({
|
|
|
35
35
|
requiredGates,
|
|
36
36
|
satisfiedGates,
|
|
37
37
|
complianceMode,
|
|
38
|
+
statePath,
|
|
39
|
+
taskId,
|
|
38
40
|
}) {
|
|
39
41
|
const candidate = createWorkState({
|
|
40
42
|
...state,
|
|
@@ -56,6 +58,6 @@ export async function synchronizePreflightState({
|
|
|
56
58
|
return state;
|
|
57
59
|
}
|
|
58
60
|
const next = { ...candidate, lastUpdated: new Date().toISOString() };
|
|
59
|
-
await writeWorkState(target, next, { packageRoot });
|
|
61
|
+
await writeWorkState(target, next, { packageRoot, taskId, statePath });
|
|
60
62
|
return next;
|
|
61
63
|
}
|
|
@@ -2,6 +2,7 @@ import { assertRouteInvariants } from "./router.js";
|
|
|
2
2
|
import { ARTIFACT_PATHS, readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
3
3
|
import { readContract } from "./contract.js";
|
|
4
4
|
import { ensureResumableState } from "./resumability.js";
|
|
5
|
+
import { taskArtifactPath } from "./task-paths.js";
|
|
5
6
|
|
|
6
7
|
export async function persistRoute(target, route, packageRoot, options = {}) {
|
|
7
8
|
assertRouteInvariants(route);
|
|
@@ -9,7 +10,7 @@ export async function persistRoute(target, route, packageRoot, options = {}) {
|
|
|
9
10
|
let contractArtifact = null;
|
|
10
11
|
if (contractFingerprint === undefined) {
|
|
11
12
|
try {
|
|
12
|
-
contractArtifact = await readContract(target, packageRoot);
|
|
13
|
+
contractArtifact = await readContract(target, packageRoot, options);
|
|
13
14
|
contractFingerprint = contractArtifact.fingerprint;
|
|
14
15
|
} catch (error) {
|
|
15
16
|
if (error.code !== "ARTIFACT_MISSING") throw error;
|
|
@@ -19,22 +20,25 @@ export async function persistRoute(target, route, packageRoot, options = {}) {
|
|
|
19
20
|
? route
|
|
20
21
|
: { ...route, contractFingerprint };
|
|
21
22
|
assertRouteInvariants(value);
|
|
23
|
+
const taskId = options.taskId ?? contractArtifact?.value?.taskId ?? null;
|
|
24
|
+
const relPath = options.routePath ?? options.routeFile ?? options.relativePath ?? (taskId ? taskArtifactPath(taskId, "route") : ARTIFACT_PATHS.route);
|
|
22
25
|
const artifact = await writeJsonArtifact(
|
|
23
26
|
target,
|
|
24
|
-
|
|
27
|
+
relPath,
|
|
25
28
|
value,
|
|
26
29
|
"routing-result",
|
|
27
30
|
packageRoot,
|
|
28
31
|
writeOptions,
|
|
29
32
|
);
|
|
30
33
|
if (contractArtifact && contractArtifact.fingerprint === artifact.value.contractFingerprint) {
|
|
31
|
-
await ensureResumableState({ target, packageRoot, contract: contractArtifact, route: artifact });
|
|
34
|
+
await ensureResumableState({ target, packageRoot, contract: contractArtifact, route: artifact, taskId });
|
|
32
35
|
}
|
|
33
36
|
return artifact;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
export async function readPersistedRoute(target, packageRoot) {
|
|
37
|
-
const
|
|
39
|
+
export async function readPersistedRoute(target, packageRoot, options = {}) {
|
|
40
|
+
const relPath = options?.routePath ?? options?.routeFile ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "route") : ARTIFACT_PATHS.route);
|
|
41
|
+
const artifact = await readJsonArtifact(target, relPath, "routing-result", packageRoot);
|
|
38
42
|
assertRouteInvariants(artifact.value);
|
|
39
43
|
return artifact;
|
|
40
44
|
}
|
package/src/core/router.js
CHANGED
|
@@ -4,6 +4,7 @@ export const ROUTING_SCHEMA_VERSION = 1;
|
|
|
4
4
|
|
|
5
5
|
const WORK_TYPES = new Set([
|
|
6
6
|
"documentation",
|
|
7
|
+
"ui-copy",
|
|
7
8
|
"code",
|
|
8
9
|
"bug",
|
|
9
10
|
"refactor",
|
|
@@ -38,6 +39,7 @@ const SIGNALS = Object.freeze({
|
|
|
38
39
|
"ci",
|
|
39
40
|
"config",
|
|
40
41
|
"critical-path",
|
|
42
|
+
"documentation",
|
|
41
43
|
]),
|
|
42
44
|
risks: new Set([
|
|
43
45
|
"untrusted-input",
|
|
@@ -101,13 +103,15 @@ const WORK_GUIDES = Object.freeze({
|
|
|
101
103
|
performance: ["performance", "test"],
|
|
102
104
|
accessibility: ["accessibility", "test"],
|
|
103
105
|
"test-only": ["test"],
|
|
104
|
-
documentation: [],
|
|
106
|
+
documentation: ["documentation"],
|
|
107
|
+
"ui-copy": ["design", "accessibility"],
|
|
105
108
|
});
|
|
106
109
|
|
|
107
110
|
const PRIMARY_GUIDES = Object.freeze({
|
|
108
111
|
"complete-website": "premium",
|
|
109
112
|
"web-game": "games",
|
|
110
|
-
documentation:
|
|
113
|
+
documentation: "documentation",
|
|
114
|
+
"ui-copy": "design",
|
|
111
115
|
});
|
|
112
116
|
|
|
113
117
|
export class RouteInputError extends Error {
|
|
@@ -192,6 +196,9 @@ export function evaluateRoute(input = {}) {
|
|
|
192
196
|
add("accessibility", "SURFACE_VIDEO");
|
|
193
197
|
}
|
|
194
198
|
if (normalized.surfaces.includes("auth")) add("security", "SURFACE_AUTH");
|
|
199
|
+
if (normalized.surfaces.includes("documentation")) {
|
|
200
|
+
add("documentation", "SURFACE_DOCUMENTATION");
|
|
201
|
+
}
|
|
195
202
|
|
|
196
203
|
for (const risk of normalized.risks) {
|
|
197
204
|
if (["untrusted-input", "personal-data", "secrets", "external-service", "publication"].includes(risk)) {
|
|
@@ -230,10 +237,6 @@ export function evaluateRoute(input = {}) {
|
|
|
230
237
|
add("security", "PLATFORM_CI");
|
|
231
238
|
}
|
|
232
239
|
|
|
233
|
-
if (normalized.workType === "documentation" && selected.size === 0) {
|
|
234
|
-
excluded.documentation = ["DOCUMENTATION_DOMAIN_GUIDE_REQUIRED"];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
240
|
for (const guide of GUIDE_IDS) {
|
|
238
241
|
if (selected.has(guide)) continue;
|
|
239
242
|
if (guide === "security") excluded[guide] = ["NO_TRUST_BOUNDARY"];
|
|
@@ -241,6 +244,7 @@ export function evaluateRoute(input = {}) {
|
|
|
241
244
|
else if (guide === "design" || guide === "accessibility") excluded[guide] = ["NO_UI_SURFACE"];
|
|
242
245
|
else if (guide === "premium" || guide === "games") excluded[guide] = ["NO_PRIMARY_WORK_TYPE"];
|
|
243
246
|
else if (guide === "taste") excluded[guide] = ["NO_TASTE_FRONTEND_CONTEXT"];
|
|
247
|
+
else if (guide === "documentation") excluded[guide] = ["NO_DOCUMENTATION_SURFACE"];
|
|
244
248
|
else excluded[guide] = ["NO_BEHAVIOR_OR_EXECUTABLE_CHANGE"];
|
|
245
249
|
}
|
|
246
250
|
|
|
@@ -281,7 +285,7 @@ export function assertRouteInvariants(result) {
|
|
|
281
285
|
}
|
|
282
286
|
}
|
|
283
287
|
for (const [guide, reasons] of Object.entries(result.excluded ?? {})) {
|
|
284
|
-
if (
|
|
288
|
+
if (!GUIDE_IDS.includes(guide)) {
|
|
285
289
|
throw invariantError("E_ROUTE_INVALID", `Route result contains unknown excluded guide: ${guide}`);
|
|
286
290
|
}
|
|
287
291
|
if (result.guides.includes(guide)) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolveTaskContext } from "./task-context.js";
|
|
2
|
+
import { withTaskLock } from "./task-lock.js";
|
|
3
|
+
|
|
4
|
+
export async function withResolvedTask(
|
|
5
|
+
target,
|
|
6
|
+
options = {},
|
|
7
|
+
callback,
|
|
8
|
+
{ explicitRequired = false } = {},
|
|
9
|
+
) {
|
|
10
|
+
const taskOption = options.taskId ?? options.task ?? null;
|
|
11
|
+
const taskContext = await resolveTaskContext(target, {
|
|
12
|
+
taskId: taskOption,
|
|
13
|
+
explicitRequired,
|
|
14
|
+
packageRoot: options.packageRoot,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return callback(taskContext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withTaskMutation(
|
|
21
|
+
target,
|
|
22
|
+
options = {},
|
|
23
|
+
operation = "mutation",
|
|
24
|
+
callback,
|
|
25
|
+
{ explicitRequired = false } = {},
|
|
26
|
+
) {
|
|
27
|
+
const taskOption = options.taskId ?? options.task ?? null;
|
|
28
|
+
const taskContext = await resolveTaskContext(target, {
|
|
29
|
+
taskId: taskOption,
|
|
30
|
+
explicitRequired,
|
|
31
|
+
packageRoot: options.packageRoot,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (taskContext) {
|
|
35
|
+
return withTaskLock(target, taskContext.taskId, operation, async () => {
|
|
36
|
+
return callback(taskContext);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return callback(null);
|
|
41
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { getPackageRoot } from "./templates.js";
|
|
2
|
+
import { assertTaskId, taskStorageKey } from "./task-identity.js";
|
|
3
|
+
import { buildTaskArtifactPaths, taskDirectory } from "./task-paths.js";
|
|
4
|
+
import { readTaskDescriptor } from "./task-descriptor.js";
|
|
5
|
+
import { discoverTasks } from "./task-discovery.js";
|
|
6
|
+
import {
|
|
7
|
+
E_TASK_AMBIGUOUS,
|
|
8
|
+
E_TASK_NOT_FOUND,
|
|
9
|
+
E_TASK_REQUIRED,
|
|
10
|
+
E_TASK_SELECTOR_CONFLICT,
|
|
11
|
+
} from "./error-codes.js";
|
|
12
|
+
|
|
13
|
+
export function createTaskContext({ target, taskId, descriptor = null, packageRoot = getPackageRoot() }) {
|
|
14
|
+
assertTaskId(taskId);
|
|
15
|
+
const taskKey = descriptor?.taskKey ?? taskStorageKey(taskId);
|
|
16
|
+
const directory = taskDirectory(taskId);
|
|
17
|
+
const paths = buildTaskArtifactPaths(taskId);
|
|
18
|
+
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
target,
|
|
21
|
+
taskId,
|
|
22
|
+
taskKey,
|
|
23
|
+
descriptor,
|
|
24
|
+
directory,
|
|
25
|
+
paths,
|
|
26
|
+
packageRoot,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveTaskContext(target, {
|
|
31
|
+
taskId = null,
|
|
32
|
+
envTaskId = process.env.FORGELOOP_TASK ?? null,
|
|
33
|
+
explicitRequired = false,
|
|
34
|
+
packageRoot = getPackageRoot(),
|
|
35
|
+
} = {}) {
|
|
36
|
+
const flagId = typeof taskId === "string" && taskId.trim() ? taskId.trim() : null;
|
|
37
|
+
const envId = typeof envTaskId === "string" && envTaskId.trim() ? envTaskId.trim() : null;
|
|
38
|
+
|
|
39
|
+
if (flagId && envId && flagId !== envId) {
|
|
40
|
+
const error = new Error(
|
|
41
|
+
`Task selector conflict: --task "${flagId}" conflicts with FORGELOOP_TASK="${envId}"`,
|
|
42
|
+
);
|
|
43
|
+
error.code = E_TASK_SELECTOR_CONFLICT;
|
|
44
|
+
error.flagTaskId = flagId;
|
|
45
|
+
error.envTaskId = envId;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const selectedId = flagId ?? envId;
|
|
50
|
+
|
|
51
|
+
if (selectedId) {
|
|
52
|
+
try {
|
|
53
|
+
const descriptorArtifact = await readTaskDescriptor(target, selectedId, packageRoot);
|
|
54
|
+
return createTaskContext({
|
|
55
|
+
target,
|
|
56
|
+
taskId: descriptorArtifact.value.taskId,
|
|
57
|
+
descriptor: descriptorArtifact.value,
|
|
58
|
+
packageRoot,
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code === E_TASK_NOT_FOUND || error.code === "ARTIFACT_MISSING") {
|
|
62
|
+
const notFoundError = new Error(`Task "${selectedId}" not found in project`);
|
|
63
|
+
notFoundError.code = E_TASK_NOT_FOUND;
|
|
64
|
+
notFoundError.taskId = selectedId;
|
|
65
|
+
throw notFoundError;
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tasks = await discoverTasks(target, packageRoot);
|
|
72
|
+
const healthyTasks = tasks.filter((t) => t.healthy !== false);
|
|
73
|
+
|
|
74
|
+
if (healthyTasks.length === 1) {
|
|
75
|
+
const single = healthyTasks[0];
|
|
76
|
+
return createTaskContext({
|
|
77
|
+
target,
|
|
78
|
+
taskId: single.taskId,
|
|
79
|
+
descriptor: single.descriptor,
|
|
80
|
+
packageRoot,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (healthyTasks.length > 1) {
|
|
85
|
+
const activeTasks = healthyTasks.filter((t) => t.phase !== "COMPLETE");
|
|
86
|
+
|
|
87
|
+
// Exactly one active (non-COMPLETE) task -> select it implicitly
|
|
88
|
+
if (activeTasks.length === 1) {
|
|
89
|
+
const single = activeTasks[0];
|
|
90
|
+
return createTaskContext({
|
|
91
|
+
target,
|
|
92
|
+
taskId: single.taskId,
|
|
93
|
+
descriptor: single.descriptor,
|
|
94
|
+
packageRoot,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Zero active and exactly one total task -> select it
|
|
99
|
+
if (activeTasks.length === 0 && healthyTasks.length === 1) {
|
|
100
|
+
const single = healthyTasks[0];
|
|
101
|
+
return createTaskContext({
|
|
102
|
+
target,
|
|
103
|
+
taskId: single.taskId,
|
|
104
|
+
descriptor: single.descriptor,
|
|
105
|
+
packageRoot,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Otherwise ambiguous
|
|
110
|
+
const candidates = (activeTasks.length > 0 ? activeTasks : healthyTasks).map((t) => t.taskId);
|
|
111
|
+
const error = new Error(
|
|
112
|
+
`Multiple active tasks exist (${candidates.join(", ")}). Select a task with --task <id> or FORGELOOP_TASK=<id>.`,
|
|
113
|
+
);
|
|
114
|
+
error.code = E_TASK_AMBIGUOUS;
|
|
115
|
+
error.tasks = candidates;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (explicitRequired) {
|
|
120
|
+
const error = new Error("A task must be specified using --task <id> or created with 'forgeloop task-create'");
|
|
121
|
+
error.code = E_TASK_REQUIRED;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
2
|
+
import { readJsonArtifact, writeJsonArtifact } from "./artifacts.js";
|
|
3
|
+
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
4
|
+
import { getPackageRoot } from "./templates.js";
|
|
5
|
+
import { assertTaskDescriptorIdentity, assertTaskId, taskStorageKey } from "./task-identity.js";
|
|
6
|
+
import { normalizeWriteClaims } from "./task-scope.js";
|
|
7
|
+
import { taskArtifactPath, TASK_STATE_ROOT } from "./task-paths.js";
|
|
8
|
+
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
9
|
+
import { E_TASK_DESCRIPTOR_INVALID, E_TASK_NOT_FOUND } from "./error-codes.js";
|
|
10
|
+
|
|
11
|
+
export const TASK_DESCRIPTOR_SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export function createTaskDescriptor({
|
|
14
|
+
taskId,
|
|
15
|
+
writeClaims = [],
|
|
16
|
+
createdAt = new Date().toISOString(),
|
|
17
|
+
updatedAt = createdAt,
|
|
18
|
+
} = {}) {
|
|
19
|
+
assertTaskId(taskId);
|
|
20
|
+
const taskKey = taskStorageKey(taskId);
|
|
21
|
+
const normalizedClaims = normalizeWriteClaims(writeClaims);
|
|
22
|
+
|
|
23
|
+
const descriptor = {
|
|
24
|
+
schemaVersion: TASK_DESCRIPTOR_SCHEMA_VERSION,
|
|
25
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
26
|
+
taskId,
|
|
27
|
+
taskKey,
|
|
28
|
+
createdAt,
|
|
29
|
+
updatedAt,
|
|
30
|
+
writeClaims: normalizedClaims,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
assertTaskDescriptorIdentity(descriptor, taskId, taskKey);
|
|
34
|
+
return descriptor;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function validateTaskDescriptor(descriptor, packageRoot = getPackageRoot()) {
|
|
38
|
+
assertTaskDescriptorIdentity(descriptor);
|
|
39
|
+
const schema = await readSchema("task-descriptor", packageRoot);
|
|
40
|
+
assertSchema(descriptor, schema, "task descriptor");
|
|
41
|
+
return descriptor;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readTaskDescriptor(target, taskIdOrKey, packageRoot = getPackageRoot()) {
|
|
45
|
+
// If it's a 64-char key, use it directly; otherwise derive storage key
|
|
46
|
+
const isKey = typeof taskIdOrKey === "string" && /^[a-f0-9]{64}$/.test(taskIdOrKey);
|
|
47
|
+
const relativePath = isKey
|
|
48
|
+
? `${TASK_STATE_ROOT}/${taskIdOrKey}/task.json`
|
|
49
|
+
: taskArtifactPath(taskIdOrKey, "descriptor");
|
|
50
|
+
|
|
51
|
+
await assertSafePath(target, relativePath);
|
|
52
|
+
const fullPath = ensureWithin(target, relativePath);
|
|
53
|
+
if (!(await fileExists(fullPath))) {
|
|
54
|
+
const error = new Error(`Task descriptor not found: ${relativePath}`);
|
|
55
|
+
error.code = E_TASK_NOT_FOUND;
|
|
56
|
+
error.artifacts = [relativePath];
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const artifact = await readJsonArtifact(target, relativePath, "task-descriptor", packageRoot);
|
|
61
|
+
try {
|
|
62
|
+
await validateTaskDescriptor(artifact.value, packageRoot);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
error.code = E_TASK_DESCRIPTOR_INVALID;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return { ...artifact, ...artifact.value, value: artifact.value };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeTaskDescriptor(target, descriptor, packageRoot = getPackageRoot(), options = {}) {
|
|
71
|
+
await validateTaskDescriptor(descriptor, packageRoot);
|
|
72
|
+
const relativePath = options.relativePathOverride ?? taskArtifactPath(descriptor.taskId, "descriptor");
|
|
73
|
+
return writeJsonArtifact(
|
|
74
|
+
target,
|
|
75
|
+
relativePath,
|
|
76
|
+
descriptor,
|
|
77
|
+
"task-descriptor",
|
|
78
|
+
packageRoot,
|
|
79
|
+
options,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { ensureWithin, fileExists } from "./filesystem.js";
|
|
3
|
+
import { getPackageRoot } from "./templates.js";
|
|
4
|
+
import { TASK_STATE_ROOT, taskArtifactPath } from "./task-paths.js";
|
|
5
|
+
import { readTaskDescriptor } from "./task-descriptor.js";
|
|
6
|
+
import { readJsonArtifact } from "./artifacts.js";
|
|
7
|
+
import { readLockInfo } from "./task-lock.js";
|
|
8
|
+
import { taskStorageKey } from "./task-identity.js";
|
|
9
|
+
|
|
10
|
+
export async function discoverTasks(target, packageRoot = getPackageRoot()) {
|
|
11
|
+
const rootPath = ensureWithin(target, TASK_STATE_ROOT);
|
|
12
|
+
if (!(await fileExists(rootPath))) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let entries = [];
|
|
17
|
+
try {
|
|
18
|
+
entries = await readdir(rootPath, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tasks = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(entry.name)) continue;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const descriptorArtifact = await readTaskDescriptor(target, entry.name, packageRoot);
|
|
30
|
+
const descriptor = descriptorArtifact.value;
|
|
31
|
+
const taskId = descriptor.taskId;
|
|
32
|
+
|
|
33
|
+
// P1-1: Verify descriptor taskKey matches the actual directory name
|
|
34
|
+
if (descriptor.taskKey !== entry.name) {
|
|
35
|
+
tasks.push({
|
|
36
|
+
taskId: descriptor.taskId ?? null,
|
|
37
|
+
taskKey: entry.name,
|
|
38
|
+
directory: `${TASK_STATE_ROOT}/${entry.name}`,
|
|
39
|
+
healthy: false,
|
|
40
|
+
error: {
|
|
41
|
+
code: "E_TASK_KEY_MISMATCH",
|
|
42
|
+
message: `Task directory key "${entry.name}" does not match descriptor taskKey "${descriptor.taskKey}"`,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check work-state if available
|
|
49
|
+
let state = null;
|
|
50
|
+
let phase = null;
|
|
51
|
+
let lastUpdated = descriptor.updatedAt ?? descriptor.createdAt;
|
|
52
|
+
try {
|
|
53
|
+
const stateArtifact = await readJsonArtifact(
|
|
54
|
+
target,
|
|
55
|
+
taskArtifactPath(taskId, "state"),
|
|
56
|
+
"work-state",
|
|
57
|
+
packageRoot,
|
|
58
|
+
);
|
|
59
|
+
state = stateArtifact.value;
|
|
60
|
+
phase = state.phase ?? null;
|
|
61
|
+
if (state.lastUpdated) {
|
|
62
|
+
lastUpdated = state.lastUpdated;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// State might not exist yet
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check lock status
|
|
69
|
+
const lockInfo = await readLockInfo(target, taskId);
|
|
70
|
+
|
|
71
|
+
// Check continuity & receipt presence
|
|
72
|
+
const continuityPath = ensureWithin(target, taskArtifactPath(taskId, "continuity"));
|
|
73
|
+
const hasContinuity = await fileExists(continuityPath);
|
|
74
|
+
|
|
75
|
+
const receiptPath = ensureWithin(target, taskArtifactPath(taskId, "receipt"));
|
|
76
|
+
const hasReceipt = await fileExists(receiptPath);
|
|
77
|
+
|
|
78
|
+
tasks.push({
|
|
79
|
+
taskId,
|
|
80
|
+
taskKey: descriptor.taskKey,
|
|
81
|
+
healthy: true,
|
|
82
|
+
phase,
|
|
83
|
+
locked: lockInfo !== null,
|
|
84
|
+
lockInfo,
|
|
85
|
+
writeClaims: descriptor.writeClaims ?? [],
|
|
86
|
+
createdAt: descriptor.createdAt,
|
|
87
|
+
updatedAt: descriptor.updatedAt,
|
|
88
|
+
lastUpdated,
|
|
89
|
+
hasContinuity,
|
|
90
|
+
hasReceipt,
|
|
91
|
+
descriptor,
|
|
92
|
+
directory: `${TASK_STATE_ROOT}/${descriptor.taskKey}`,
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// P1-2: Surface corrupt task namespaces instead of silently hiding them
|
|
96
|
+
tasks.push({
|
|
97
|
+
taskId: null,
|
|
98
|
+
taskKey: entry.name,
|
|
99
|
+
directory: `${TASK_STATE_ROOT}/${entry.name}`,
|
|
100
|
+
healthy: false,
|
|
101
|
+
error: {
|
|
102
|
+
code: err.code ?? "E_TASK_DESCRIPTOR_INVALID",
|
|
103
|
+
message: err.message ?? String(err),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return tasks.sort((a, b) => (a.taskId ?? a.taskKey).localeCompare(b.taskId ?? b.taskKey));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function findTaskById(target, taskId, packageRoot = getPackageRoot()) {
|
|
113
|
+
const taskKey = taskStorageKey(taskId);
|
|
114
|
+
const tasks = await discoverTasks(target, packageRoot);
|
|
115
|
+
return tasks.find((t) => t.healthy !== false && (t.taskId === taskId || t.taskKey === taskKey)) ?? null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
E_TASK_DESCRIPTOR_INVALID,
|
|
4
|
+
E_TASK_KEY_MISMATCH,
|
|
5
|
+
E_TASK_REQUIRED,
|
|
6
|
+
} from "./error-codes.js";
|
|
7
|
+
|
|
8
|
+
const TASK_KEY_REGEX = /^[a-f0-9]{64}$/;
|
|
9
|
+
|
|
10
|
+
export function taskStorageKey(taskId) {
|
|
11
|
+
assertTaskId(taskId);
|
|
12
|
+
return createHash("sha256")
|
|
13
|
+
.update(taskId, "utf8")
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function assertTaskId(taskId, label = "taskId") {
|
|
18
|
+
if (typeof taskId !== "string" || taskId.trim() === "") {
|
|
19
|
+
const error = new Error(`${label} must be a non-empty string`);
|
|
20
|
+
error.code = E_TASK_REQUIRED;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (taskId.length > 256) {
|
|
24
|
+
const error = new Error(`${label} must not exceed 256 characters`);
|
|
25
|
+
error.code = E_TASK_DESCRIPTOR_INVALID;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
return taskId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function assertTaskKey(taskKey, label = "taskKey") {
|
|
32
|
+
if (typeof taskKey !== "string" || !TASK_KEY_REGEX.test(taskKey)) {
|
|
33
|
+
const error = new Error(`${label} must be a 64-character lowercase hexadecimal string`);
|
|
34
|
+
error.code = E_TASK_KEY_MISMATCH;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return taskKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertTaskDescriptorIdentity(descriptor, expectedTaskId = null, expectedTaskKey = null) {
|
|
41
|
+
if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
|
|
42
|
+
const error = new Error("Task descriptor must be a valid object");
|
|
43
|
+
error.code = E_TASK_DESCRIPTOR_INVALID;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
assertTaskId(descriptor.taskId, "descriptor.taskId");
|
|
48
|
+
assertTaskKey(descriptor.taskKey, "descriptor.taskKey");
|
|
49
|
+
|
|
50
|
+
const derivedKey = taskStorageKey(descriptor.taskId);
|
|
51
|
+
if (descriptor.taskKey !== derivedKey) {
|
|
52
|
+
const error = new Error(
|
|
53
|
+
`Task key mismatch: descriptor has "${descriptor.taskKey}", but hash of "${descriptor.taskId}" is "${derivedKey}"`,
|
|
54
|
+
);
|
|
55
|
+
error.code = E_TASK_KEY_MISMATCH;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (expectedTaskId && descriptor.taskId !== expectedTaskId) {
|
|
60
|
+
const error = new Error(
|
|
61
|
+
`Task ID mismatch: expected "${expectedTaskId}", but descriptor contains "${descriptor.taskId}"`,
|
|
62
|
+
);
|
|
63
|
+
error.code = E_TASK_DESCRIPTOR_INVALID;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (expectedTaskKey && descriptor.taskKey !== expectedTaskKey) {
|
|
68
|
+
const error = new Error(
|
|
69
|
+
`Task key mismatch: expected "${expectedTaskKey}", but descriptor contains "${descriptor.taskKey}"`,
|
|
70
|
+
);
|
|
71
|
+
error.code = E_TASK_KEY_MISMATCH;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return descriptor;
|
|
76
|
+
}
|