@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.
Files changed (95) hide show
  1. package/.cursor/rules/project-loop.mdc +3 -2
  2. package/.github/copilot-instructions.md +1 -0
  3. package/AGENTS.md +2 -1
  4. package/CLAUDE.md +1 -0
  5. package/DOCS_INDEX.md +36 -0
  6. package/ENG/design-code-eng.md +31 -0
  7. package/ENG/documentation-quality-eng.md +400 -0
  8. package/EXECUTION_STATE.md +23 -13
  9. package/GUIDE_ROUTER.md +23 -8
  10. package/LOOP_ENGINEERING.md +77 -12
  11. package/PROTOCOL_INTEGRATION.md +9 -6
  12. package/README.md +82 -39
  13. package/docs/ARTIFACT_REFERENCE.md +442 -0
  14. package/docs/CLI_REFERENCE.md +892 -0
  15. package/docs/CROSS_HARNESS_CONTINUITY.md +198 -0
  16. package/docs/DOCUMENTATION_GUIDE.md +161 -0
  17. package/docs/GETTING_STARTED.md +348 -0
  18. package/docs/RECIPES.md +250 -0
  19. package/docs/TROUBLESHOOTING.md +345 -0
  20. package/docs/assets/forgeloop-flow.svg +1 -1
  21. package/docs/forgeloop-flow.mmd +1 -1
  22. package/package.json +13 -2
  23. package/schemas/task-descriptor.schema.json +56 -0
  24. package/schemas/work-state.schema.json +18 -1
  25. package/scripts/CI_VALIDATORS.md +7 -0
  26. package/src/cli.js +280 -388
  27. package/src/commands/advance.js +5 -2
  28. package/src/commands/audit.js +11 -1
  29. package/src/commands/clear-continuity.js +5 -2
  30. package/src/commands/clear-state.js +5 -2
  31. package/src/commands/complete.js +9 -1
  32. package/src/commands/continuity.js +5 -2
  33. package/src/commands/inspect.js +10 -2
  34. package/src/commands/next.js +5 -2
  35. package/src/commands/preflight.js +9 -1
  36. package/src/commands/prepare-completion.js +5 -2
  37. package/src/commands/reconcile-continuity.js +5 -2
  38. package/src/commands/record-check.js +7 -1
  39. package/src/commands/record-continuity.js +21 -14
  40. package/src/commands/record-terminal-result.js +7 -1
  41. package/src/commands/route.js +22 -18
  42. package/src/commands/run-check.js +52 -44
  43. package/src/commands/status.js +18 -12
  44. package/src/commands/task-create.js +94 -0
  45. package/src/commands/task-list.js +48 -0
  46. package/src/commands/task-migrate.js +34 -0
  47. package/src/commands/task-scope.js +75 -0
  48. package/src/commands/task-show.js +81 -0
  49. package/src/commands/task-unlock.js +35 -0
  50. package/src/commands/validate-protocol.js +37 -20
  51. package/src/commands/validate-state.js +24 -18
  52. package/src/config/guides.json +42 -0
  53. package/src/core/activation.js +8 -4
  54. package/src/core/artifact-registry.js +166 -0
  55. package/src/core/audit.js +65 -12
  56. package/src/core/bundles.js +76 -50
  57. package/src/core/cli-command-definitions.js +611 -0
  58. package/src/core/cli-metadata.js +23 -0
  59. package/src/core/completion-artifacts.js +161 -74
  60. package/src/core/completion.js +134 -76
  61. package/src/core/continuity.js +20 -13
  62. package/src/core/contract.js +6 -3
  63. package/src/core/error-codes.js +197 -0
  64. package/src/core/events.js +19 -14
  65. package/src/core/execution.js +38 -6
  66. package/src/core/gate-artifact.js +12 -9
  67. package/src/core/gates.js +4 -2
  68. package/src/core/guide-metadata.js +7 -11
  69. package/src/core/guide-registry.js +29 -0
  70. package/src/core/inspect.js +7 -4
  71. package/src/core/native-adapters.js +6 -0
  72. package/src/core/phase.js +85 -33
  73. package/src/core/preflight-consistency.js +24 -14
  74. package/src/core/preflight-loaders.js +16 -11
  75. package/src/core/preflight.js +44 -25
  76. package/src/core/protocol.js +2 -11
  77. package/src/core/receipt.js +1 -1
  78. package/src/core/report.js +2 -2
  79. package/src/core/repository.js +46 -12
  80. package/src/core/resumability.js +6 -4
  81. package/src/core/route-artifact.js +9 -5
  82. package/src/core/router.js +11 -7
  83. package/src/core/schema-validation.js +1 -0
  84. package/src/core/task-command.js +41 -0
  85. package/src/core/task-context.js +126 -0
  86. package/src/core/task-descriptor.js +81 -0
  87. package/src/core/task-discovery.js +116 -0
  88. package/src/core/task-identity.js +76 -0
  89. package/src/core/task-lock.js +209 -0
  90. package/src/core/task-migration-validation.js +140 -0
  91. package/src/core/task-migration.js +361 -0
  92. package/src/core/task-paths.js +96 -0
  93. package/src/core/task-scope.js +179 -0
  94. package/src/core/templates.js +3 -9
  95. package/src/core/work-state.js +24 -13
@@ -18,19 +18,53 @@ export async function currentRepositoryFingerprint(target) {
18
18
  }
19
19
  }
20
20
 
21
- export async function currentChangedPaths(target) {
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 { stdout } = await execFileAsync(
24
- "git",
25
- ["-C", target, "status", "--porcelain=v1", "--untracked-files=all"],
26
- { windowsHide: true },
27
- );
28
- return stdout
29
- .split(/\r?\n/)
30
- .filter(Boolean)
31
- .map((line) => line.slice(3).trim())
32
- .filter((relativePath) => relativePath && !relativePath.startsWith(".forgeloop/"))
33
- .sort();
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
  }
@@ -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
- ARTIFACT_PATHS.route,
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 artifact = await readJsonArtifact(target, ARTIFACT_PATHS.route, "routing-result", packageRoot);
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
  }
@@ -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: null,
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 (guide !== "documentation" && !GUIDE_IDS.includes(guide)) {
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)) {
@@ -27,6 +27,7 @@ export const SHIPPED_SCHEMA_NAMES = Object.freeze([
27
27
  "task-bundle",
28
28
  "execution",
29
29
  "authority",
30
+ "task-descriptor",
30
31
  ]);
31
32
 
32
33
  export class SchemaValidationError extends Error {
@@ -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
+ }