ai-delivery-workflow 0.5.0 → 0.5.1

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 (48) hide show
  1. package/README.md +5 -6
  2. package/bin/ai-delivery.mjs +2 -24
  3. package/docs/CLI-PARAMETER-REFERENCE.zh-CN.md +6 -67
  4. package/docs/DUAL-REPOSITORY-WORKSPACE.zh-CN.md +1 -1
  5. package/docs/FILE-REFERENCE.zh-CN.md +6 -28
  6. package/docs/MAINTENANCE-VERIFICATION.zh-CN.md +2 -2
  7. package/docs/PROJECT-MANUAL.zh-CN.md +23 -38
  8. package/docs/STATE-CLI-MAINTENANCE.zh-CN.md +8 -10
  9. package/docs/STATE-CLI-USER-GUIDE.zh-CN.md +6 -16
  10. package/docs/agents/workflow-manager-acceptance-matrix.md +9 -15
  11. package/docs/agents/workflow-manager-current-state.md +12 -15
  12. package/lib/delivery-state.mjs +59 -910
  13. package/lib/fs-utils.mjs +0 -16
  14. package/lib/project-bootstrap.mjs +21 -58
  15. package/lib/project-installer.mjs +24 -180
  16. package/lib/project-repair.mjs +217 -0
  17. package/lib/toml-hooks.mjs +2 -4
  18. package/lib/workflow-manager-governance.mjs +3 -47
  19. package/lib/workspace.mjs +2 -3
  20. package/package.json +2 -2
  21. package/skills/ai-delivery-bootstrap/SKILL.md +1 -1
  22. package/skills/ai-delivery-bootstrap/references/bootstrap-contract.md +1 -1
  23. package/skills/ai-delivery-checkpoint-task/SKILL.md +0 -4
  24. package/skills/ai-delivery-checkpoint-task/references/checkpoint-contract.md +2 -2
  25. package/skills/ai-delivery-checkpoint-task/scripts/hook-event.mjs +2 -8
  26. package/skills/ai-delivery-checkpoint-task/scripts/task-state.mjs +26 -46
  27. package/skills/ai-delivery-design-experience/SKILL.md +3 -3
  28. package/skills/ai-delivery-design-experience/references/experience-contract.md +1 -11
  29. package/skills/ai-delivery-design-experience/references/prototype-management-contract.md +1 -1
  30. package/skills/ai-delivery-orchestrate/SKILL.md +5 -7
  31. package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/hooks/hook-event.mjs +2 -8
  32. package/skills/ai-delivery-orchestrate/assets/project-template/AGENTS.md +4 -5
  33. package/skills/ai-delivery-orchestrate/references/formal-state-contract.md +11 -20
  34. package/skills/ai-delivery-orchestrate/references/workflow-model.md +1 -3
  35. package/skills/ai-delivery-prepare-release/assets/release-template/release-manifest.yaml +0 -2
  36. package/docs/PROTOTYPE-MIGRATION.zh-CN.md +0 -164
  37. package/docs/VIEWER-MAINTENANCE.zh-CN.md +0 -150
  38. package/docs/VIEWER-USER-GUIDE.zh-CN.md +0 -113
  39. package/lib/project-migration.mjs +0 -1193
  40. package/lib/prototype-migration.mjs +0 -713
  41. package/skills/ai-delivery-design-experience/assets/experience-template/LEGACY-COMPATIBILITY.md +0 -16
  42. package/skills/ai-delivery-design-experience/assets/experience-template/canvas-catalog.csv +0 -1
  43. package/skills/ai-delivery-design-experience/assets/experience-template/canvas-pages.csv +0 -1
  44. package/skills/ai-delivery-design-experience/assets/experience-template/page-catalog.csv +0 -1
  45. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-file-terminals.csv +0 -1
  46. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-files.csv +0 -1
  47. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-manifest.yaml +0 -76
  48. package/skills/ai-delivery-design-experience/assets/experience-template/prototype-set.yaml +0 -24
@@ -0,0 +1,217 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { atomicWrite, filesUnder } from "./fs-utils.mjs";
6
+
7
+ function toPosix(value) {
8
+ return value.replaceAll("\\", "/");
9
+ }
10
+
11
+ function sha256(value) {
12
+ return crypto.createHash("sha256").update(value).digest("hex");
13
+ }
14
+
15
+ function treeDigest(root) {
16
+ const hash = crypto.createHash("sha256");
17
+ for (const file of filesUnder(root)) {
18
+ hash.update(toPosix(path.relative(root, file)));
19
+ hash.update("\0");
20
+ hash.update(fs.readFileSync(file));
21
+ hash.update("\0");
22
+ }
23
+ return hash.digest("hex");
24
+ }
25
+
26
+ function readStructured(file, label) {
27
+ try {
28
+ const text = fs.readFileSync(file, "utf8");
29
+ const value = path.extname(file).toLowerCase() === ".json" ? JSON.parse(text) : parseYaml(text);
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a mapping");
31
+ return value;
32
+ } catch (error) {
33
+ throw new Error(`Cannot read ${label} (${toPosix(file)}): ${error.message}`);
34
+ }
35
+ }
36
+
37
+ function stableValue(value) {
38
+ if (Array.isArray(value)) return value.map(stableValue);
39
+ if (!value || typeof value !== "object") return value;
40
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
41
+ }
42
+
43
+ function stableJson(value) {
44
+ return JSON.stringify(stableValue(value));
45
+ }
46
+
47
+ function uniqueSorted(values) {
48
+ return [...new Set((Array.isArray(values) ? values : []).map(String))].sort();
49
+ }
50
+
51
+ function unsignedRepairRecord(record) {
52
+ const unsigned = { ...record };
53
+ delete unsigned.record_checksum;
54
+ return unsigned;
55
+ }
56
+
57
+ function readRepairTransactions(project) {
58
+ const directory = path.join(project, ".workflow", "control", "repairs", "transactions");
59
+ const records = [];
60
+ let previousChecksum = null;
61
+ for (const file of filesUnder(directory).filter((item) => item.endsWith(".json"))) {
62
+ const record = readStructured(file, "repair transaction");
63
+ if (record.schema_version !== 1
64
+ || !Number.isInteger(record.sequence)
65
+ || record.sequence !== records.length + 1
66
+ || record.previous_checksum !== previousChecksum
67
+ || record.record_checksum !== sha256(stableJson(unsignedRepairRecord(record)))) {
68
+ throw new Error(`Repair transaction chain is invalid: ${toPosix(path.relative(project, file))}.`);
69
+ }
70
+ records.push(record);
71
+ previousChecksum = record.record_checksum;
72
+ }
73
+ return records;
74
+ }
75
+
76
+ function archiveIterationId(value) {
77
+ const iterationId = String(value || "");
78
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(iterationId)) {
79
+ throw new Error(`Invalid archive iteration ID: ${iterationId || "missing"}.`);
80
+ }
81
+ return iterationId;
82
+ }
83
+
84
+ function verifiedArchiveInvalidations(project, records) {
85
+ for (const record of records.filter((item) => item.transaction_type === "archive-invalidation")) {
86
+ const iterationId = archiveIterationId(record.target?.iteration_id);
87
+ const expectedArchivePath = `.workflow/control/archives/versions/${iterationId}`;
88
+ const expectedManifestPath = `${expectedArchivePath}/version-manifest.json`;
89
+ if (record.target.archive_path !== expectedArchivePath
90
+ || record.target.manifest_path !== expectedManifestPath
91
+ || !/^[a-f0-9]{64}$/.test(record.target.archive_sha256 || "")
92
+ || !/^[a-f0-9]{64}$/.test(record.target.manifest_sha256 || "")) {
93
+ throw new Error(`Archive invalidation target is invalid: ${record.transaction_id}.`);
94
+ }
95
+ const archiveDirectory = path.join(project, ...expectedArchivePath.split("/"));
96
+ const manifestFile = path.join(project, ...expectedManifestPath.split("/"));
97
+ if (!fs.existsSync(manifestFile)
98
+ || treeDigest(archiveDirectory) !== record.target.archive_sha256
99
+ || sha256(fs.readFileSync(manifestFile)) !== record.target.manifest_sha256) {
100
+ throw new Error(`Invalidated archive ${iterationId} bytes changed after ${record.transaction_id}.`);
101
+ }
102
+ }
103
+ }
104
+
105
+ function repairTransactionPath(sequence, transactionId) {
106
+ return `.workflow/control/repairs/transactions/${String(sequence).padStart(6, "0")}-${transactionId}.json`;
107
+ }
108
+
109
+ export function repairProject(project, options = {}) {
110
+ const root = path.resolve(project);
111
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
112
+ throw new Error(`Project directory does not exist: ${root}`);
113
+ }
114
+ const iterationId = archiveIterationId(options.invalidateArchive);
115
+ const reason = String(options.reason || "").trim();
116
+ const actor = String(options.actor || "").trim();
117
+ const evidence = uniqueSorted(options.evidence);
118
+ if (!reason) throw new Error("Archive invalidation requires --reason.");
119
+ if (!actor.startsWith("human:")) {
120
+ throw new Error("Archive invalidation requires an explicit human actor through --actor human:<identity>.");
121
+ }
122
+ if (evidence.length < 2) {
123
+ throw new Error("Archive invalidation requires at least two --evidence values for RCA and recovery evidence.");
124
+ }
125
+ const archiveDirectory = path.join(root, ".workflow", "control", "archives", "versions", iterationId);
126
+ const manifestFile = path.join(archiveDirectory, "version-manifest.json");
127
+ if (!fs.existsSync(manifestFile)) throw new Error(`Version archive not found: ${iterationId}.`);
128
+ const manifest = readStructured(manifestFile, "version archive manifest");
129
+ if (manifest.iteration_id !== iterationId) {
130
+ throw new Error(`Version archive identity mismatch: expected ${iterationId}, found ${manifest.iteration_id || "missing"}.`);
131
+ }
132
+ const target = {
133
+ iteration_id: iterationId,
134
+ archive_path: toPosix(path.relative(root, archiveDirectory)),
135
+ manifest_path: toPosix(path.relative(root, manifestFile)),
136
+ manifest_sha256: sha256(fs.readFileSync(manifestFile)),
137
+ archive_sha256: treeDigest(archiveDirectory),
138
+ original_bytes_preserved: true,
139
+ };
140
+ const identity = { transaction_type: "archive-invalidation", target, correction: { actor, reason, evidence } };
141
+ const idempotencyKey = `archive-invalidation:${iterationId}:${sha256(stableJson(identity))}`;
142
+ const existingTransactions = readRepairTransactions(root);
143
+ verifiedArchiveInvalidations(root, existingTransactions);
144
+ const existing = existingTransactions.find((record) => record.idempotency_key === idempotencyKey);
145
+ const sequence = existing?.sequence || existingTransactions.length + 1;
146
+ const transactionId = existing?.transaction_id || `REPAIR-${sha256(idempotencyKey).slice(0, 16).toUpperCase()}`;
147
+ const transactionPath = repairTransactionPath(sequence, transactionId);
148
+ const report = {
149
+ command: "repair",
150
+ action: "archive-invalidation",
151
+ dry_run: Boolean(options.dryRun),
152
+ applied: false,
153
+ forward_only: true,
154
+ original_bytes_preserved: true,
155
+ target,
156
+ actor,
157
+ reason,
158
+ evidence,
159
+ transaction: {
160
+ transaction_id: transactionId,
161
+ path: transactionPath,
162
+ previous_checksum: existing?.previous_checksum || existingTransactions.at(-1)?.record_checksum || null,
163
+ },
164
+ eligibility: existing
165
+ ? { iteration_id: iterationId, current: "invalidated", source_transaction_id: transactionId }
166
+ : { iteration_id: iterationId, current: "eligible", source_transaction_id: null },
167
+ };
168
+ if (report.dry_run || existing) return report;
169
+
170
+ const repairRoot = path.join(root, ".workflow", "control", "repairs");
171
+ const lockFile = path.join(repairRoot, ".repair.lock");
172
+ fs.mkdirSync(repairRoot, { recursive: true });
173
+ let lock;
174
+ try {
175
+ lock = fs.openSync(lockFile, "wx");
176
+ } catch (error) {
177
+ throw new Error(`Repair transaction is locked by another writer: ${error.message}`);
178
+ }
179
+ try {
180
+ const currentTransactions = readRepairTransactions(root);
181
+ const duplicate = currentTransactions.find((record) => record.idempotency_key === idempotencyKey);
182
+ if (duplicate) {
183
+ report.transaction.transaction_id = duplicate.transaction_id;
184
+ report.transaction.path = repairTransactionPath(duplicate.sequence, duplicate.transaction_id);
185
+ report.eligibility = { iteration_id: iterationId, current: "invalidated", source_transaction_id: duplicate.transaction_id };
186
+ return report;
187
+ }
188
+ if (currentTransactions.length !== existingTransactions.length
189
+ || (currentTransactions.at(-1)?.record_checksum || null) !== (existingTransactions.at(-1)?.record_checksum || null)) {
190
+ throw new Error("Repair transaction revision changed during apply; rerun the command.");
191
+ }
192
+ if (treeDigest(archiveDirectory) !== target.archive_sha256
193
+ || sha256(fs.readFileSync(manifestFile)) !== target.manifest_sha256) {
194
+ throw new Error(`Version archive changed during repair apply: ${iterationId}.`);
195
+ }
196
+ const transaction = {
197
+ schema_version: 1,
198
+ sequence,
199
+ transaction_id: transactionId,
200
+ transaction_type: "archive-invalidation",
201
+ idempotency_key: idempotencyKey,
202
+ occurred_at: new Date().toISOString(),
203
+ target,
204
+ correction: { actor, reason, evidence },
205
+ previous_checksum: existingTransactions.at(-1)?.record_checksum || null,
206
+ };
207
+ transaction.record_checksum = sha256(stableJson(transaction));
208
+ atomicWrite(path.join(root, ...transactionPath.split("/")), `${JSON.stringify(transaction, null, 2)}\n`);
209
+ report.applied = true;
210
+ report.transaction.previous_checksum = transaction.previous_checksum;
211
+ report.eligibility = { iteration_id: iterationId, current: "invalidated", source_transaction_id: transactionId };
212
+ return report;
213
+ } finally {
214
+ if (lock !== undefined) fs.closeSync(lock);
215
+ fs.rmSync(lockFile, { force: true });
216
+ }
217
+ }
@@ -14,7 +14,6 @@ export const HOOK_EVENTS = [
14
14
  ];
15
15
 
16
16
  const HOOK_COMMAND = "node .workflow/tools/hooks/hook-event.mjs";
17
- const LEGACY_HOOK_COMMAND = "node .ai-delivery/hooks/hook-event.mjs";
18
17
 
19
18
  function hookBlock(event, newline) {
20
19
  return [
@@ -102,9 +101,8 @@ export function mergeHookConfig(existing, { force = false } = {}) {
102
101
  const newline = existing.includes("\r\n") ? "\r\n" : "\n";
103
102
  let text = existing.trimEnd();
104
103
  const actions = [];
105
- if (text.includes(LEGACY_HOOK_COMMAND)) {
106
- text = text.replaceAll(LEGACY_HOOK_COMMAND, HOOK_COMMAND);
107
- actions.push("migrate AI delivery Hook command into .workflow/tools");
104
+ if (text.includes("node .ai-delivery/hooks/hook-event.mjs")) {
105
+ throw new Error("Unsupported legacy Hook command; remove the .ai-delivery handler before installing the current Hook.");
108
106
  }
109
107
  if (force) {
110
108
  const refreshed = removeDeliveryHandlerBlocks(text);
@@ -24,7 +24,6 @@ export const WORKFLOW_MANAGER_GOVERNANCE_SCHEMA_VERSION = "workflow-manager.gove
24
24
  const RELATIONS_FILE = ".workflow/control/relations.yaml";
25
25
  const SETTINGS_FILE = ".workflow/config/manager-settings.json";
26
26
  const RECEIPTS_ROOT = ".workflow/control/receipts";
27
- const EVENTS_FILE = ".workflow/control/events/workflow-manager-events.jsonl";
28
27
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/;
29
28
  const SAFE_ENTITY_TYPE = /^[a-z][a-z0-9-]{0,63}$/;
30
29
  const RELATION_TYPES = new Set([
@@ -59,18 +58,6 @@ function contained(root, relative) {
59
58
  return absolute;
60
59
  }
61
60
 
62
- function atomicWrite(file, content) {
63
- fs.mkdirSync(path.dirname(file), { recursive: true });
64
- const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
65
- fs.writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" });
66
- try {
67
- fs.renameSync(temporary, file);
68
- } catch (error) {
69
- fs.rmSync(temporary, { force: true });
70
- throw error;
71
- }
72
- }
73
-
74
61
  function requireHuman(actor) {
75
62
  if (!/^human:[A-Za-z0-9._@/-]+$/.test(String(actor || ""))) {
76
63
  throw new Error("Governed confirmation requires actor human:<identity>");
@@ -92,16 +79,13 @@ function receiptFiles(root) {
92
79
 
93
80
  function replay(root, action, input, subject = null) {
94
81
  if (!input.idempotency_key) return null;
95
- const legacyRequestChecksum = checksum({ action, input });
96
- const governedRequestChecksum = workflowManagerRequestChecksum(action, input, subject);
82
+ const expectedRequestChecksum = workflowManagerRequestChecksum(action, input, subject);
97
83
  for (const file of receiptFiles(root)) {
98
84
  try {
99
85
  const receipt = JSON.parse(fs.readFileSync(file, "utf8"));
100
86
  if (receipt.action !== action || receipt.idempotency_key !== input.idempotency_key) continue;
101
- const legacy = receipt.request_checksum === legacyRequestChecksum;
102
- const governed = receipt.request_checksum === governedRequestChecksum
103
- && (receipt.subject ?? null) === subject;
104
- if (!legacy && !governed) {
87
+ if (receipt.request_checksum !== expectedRequestChecksum
88
+ || (receipt.subject ?? null) !== subject) {
105
89
  throw new Error("Idempotency key was already used for a different request");
106
90
  }
107
91
  return receipt;
@@ -112,34 +96,6 @@ function replay(root, action, input, subject = null) {
112
96
  return null;
113
97
  }
114
98
 
115
- function record(root, action, input, detail = {}) {
116
- const timestamp = new Date().toISOString();
117
- const receipt = {
118
- schema_version: 1,
119
- receipt_id: `WMG-${crypto.randomUUID()}`,
120
- action,
121
- actor: input.actor,
122
- idempotency_key: input.idempotency_key || null,
123
- request_checksum: checksum({ action, input }),
124
- applied_at: timestamp,
125
- ...detail,
126
- };
127
- receipt.receipt_checksum = checksum(receipt);
128
- atomicWrite(contained(root, `${RECEIPTS_ROOT}/${receipt.receipt_id}.json`), `${JSON.stringify(receipt, null, 2)}\n`);
129
- const event = {
130
- schema_version: 1,
131
- event_id: `WME-${crypto.randomUUID()}`,
132
- type: `workflow-manager.${action}`,
133
- occurred_at: timestamp,
134
- receipt_id: receipt.receipt_id,
135
- subject: detail.subject || null,
136
- };
137
- const eventFile = contained(root, EVENTS_FILE);
138
- fs.mkdirSync(path.dirname(eventFile), { recursive: true });
139
- fs.appendFileSync(eventFile, `${JSON.stringify(event)}\n`, "utf8");
140
- return { receipt, event };
141
- }
142
-
143
99
  function relationsDocument(root) {
144
100
  const file = contained(root, RELATIONS_FILE);
145
101
  if (!fs.existsSync(file)) return { file, document: { schema_version: 1, revision: 0, relations: [] }, checksum: null };
package/lib/workspace.mjs CHANGED
@@ -4,7 +4,6 @@ import { spawnSync } from "node:child_process";
4
4
  import { parse as parseYaml, stringify as stringifyYaml } from "./yaml-runtime.mjs";
5
5
 
6
6
  export const WORKSPACE_CONFIG = path.join(".workflow", "config", "workspace.yaml");
7
- export const LEGACY_WORKSPACE_CONFIG = path.join(".ai-delivery", "workspace.yaml");
8
7
 
9
8
  function toPosix(value) {
10
9
  return value.replaceAll("\\", "/");
@@ -114,7 +113,7 @@ function initializeWorkflowRepository(workspaceRoot, branch, remote, heads, acti
114
113
  const current = gitBranch(workspaceRoot);
115
114
  if (current !== branch) {
116
115
  if (gitHasCommit(workspaceRoot)) {
117
- throw new Error(`Existing workflow repository must already use ${branch}; automatic migration is not supported.`);
116
+ throw new Error(`Existing workflow repository must already use ${branch}; automatic branch changes are not supported.`);
118
117
  }
119
118
  if (!dryRun) runGit(workspaceRoot, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]);
120
119
  }
@@ -172,7 +171,7 @@ export function validateCodePath(workspaceRoot, codePath = "code") {
172
171
  if (!normalized || normalized.includes("/") || normalized === "." || normalized === "..") {
173
172
  throw new Error("Code path must be a relative direct child directory.");
174
173
  }
175
- if (new Set([".git", ".workflow", ".ai-delivery", ".agents", ".codex"]).has(normalized)) {
174
+ if (new Set([".git", ".workflow", ".agents", ".codex"]).has(normalized)) {
176
175
  throw new Error(`Reserved code path: ${normalized}`);
177
176
  }
178
177
  const absolute = path.resolve(workspaceRoot, normalized);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-delivery-workflow",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Project-scoped Codex skills and hooks for artifact-driven AI software delivery",
5
5
  "type": "module",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "skills"
23
23
  ],
24
24
  "scripts": {
25
- "test": "node --test verification/*.test.mjs",
25
+ "test": "node --test --test-concurrency=4 verification/*.test.mjs",
26
26
  "manager:build": "npm --prefix manager run build",
27
27
  "skills:validate": "node bin/validate-skills.mjs",
28
28
  "audit:generate": "node audit/generate-all.mjs",
@@ -44,7 +44,7 @@ The first product question is the safe stopping point. Do not invent the initial
44
44
  - `codebase-uninitialized`: use packaged `ai-delivery bootstrap`, review the repository baseline, inventory, context index, and adoption assessment, then invoke `$ai-delivery-define-product` to distinguish observed behavior from intended product behavior.
45
45
  - `workflow-interrupted`: do not write bootstrap artifacts. If `runtime.next_action_owner` is `user`, return the recorded `next_action` as the single business question and perform no recovery, activation, checkpoint, task, artifact, or Git mutation. If it is `external`, report the recorded wait condition without mutation. Only an `agent`-owned next action may invoke `$ai-delivery-checkpoint-task recover`, reconcile reality, inspect the pending Evolution queue without applying changes mid-task, and continue `recommended_task_id`. When required input arrives, transfer ownership with both `--next-action-owner agent` and a new idempotent `--next-action`.
46
46
  - `workflow-ready`: when a queued task is recommended, first apply the same `next_action_owner` boundary; activate it through `$ai-delivery-checkpoint-task` only when the owner is `agent`. Otherwise use the reported specialist skill or `$ai-delivery-orchestrate` to derive the next development node. Treat `release-ready` versions as completed development evidence, not implicit release intent. Route to `$ai-delivery-orchestrate-release` only when the user explicitly requests a release.
47
- - `workflow-inconsistent`: stop automatic mutation, run the available project/package doctor internally, report exact conflicts, and obtain a repair decision. An installed project must use packaged `upgrade --dry-run` for managed installation drift and `migrate --dry-run` for legacy schema, task grouping, anchor, or archive conflicts rather than repeat `init`. Structural migration copies legacy-root workflow material, adds explicit task attempts, and preserves lifecycle and scope facts; grouping ambiguities require a source-checksum-bound `--decisions` document with a `human:` actor and rationale. Route an approved premature-archive correction through packaged `repair --invalidate-archive` with a human actor, reason, RCA evidence, and recovery evidence; the original archive remains immutable. Use `upgrade --force` only when the user explicitly approves replacing the reported managed files.
47
+ - `workflow-inconsistent`: stop automatic mutation, run the available project/package doctor internally, report exact conflicts, and obtain an explicit repair decision. Use `upgrade --dry-run` for current managed installation drift; malformed current state, missing anchors, archive conflicts, and checksum drift fail closed until repaired through the governed current-state interface. Use `upgrade --force` only when the user explicitly approves replacing the reported managed files.
48
48
 
49
49
  ## Brownfield Rules
50
50
 
@@ -28,7 +28,7 @@ After installation, `node .workflow/tools/bootstrap/bootstrap.mjs inspect` is th
28
28
 
29
29
  `ai-delivery bootstrap <project-root>` may initialize only `empty-uninitialized` and `codebase-uninitialized` projects through the project-scoped installer. Preserve existing code and configuration. On partial or conflicting installation, return `workflow-inconsistent` without writes. `--force` is allowed only after explicit user approval.
30
30
 
31
- For an installed legacy or conflicting state, keep Bootstrap read-only. Use packaged `migrate --dry-run` to report schema conversion, semantic task groups and ambiguities, state/archive conflicts, verified anchors, and exact file changes. A legacy root migration copies `delivery/`, `control/`, and `standards/` into `.workflow/` while preserving source bytes; flat task shards gain explicit attempts with checksummed rollback backups. Resolve grouping ambiguities only through a checksum-bound `--decisions` document with a `human:` actor and rationale. Migration must not activate or close tasks, infer completion, change scope, or rewrite legacy events. Incorrect archives require the separate packaged `repair --invalidate-archive` forward transaction with an explicit human actor, reason, RCA evidence, and recovery evidence; the original archive bytes remain authoritative history.
31
+ For an installed or conflicting state, keep Bootstrap read-only and return `workflow-inconsistent` with the failing path and remediation. Use the packaged `repair --invalidate-archive` forward transaction only for an explicitly identified archive integrity defect; it records a human actor, reason, RCA evidence, and recovery evidence without rewriting immutable history.
32
32
 
33
33
  For an uninitialized project, generate repository inventory and context artifacts from the first read-only scan snapshot. Do not let installer-generated `AGENTS.md`, `.gitignore`, Hook, skill, standard, or delivery files contaminate the observed pre-install codebase baseline.
34
34
 
@@ -11,10 +11,6 @@ Treat explicit task snapshots as authoritative. Treat Hook events as an audit tr
11
11
 
12
12
  Write portable task state to `.workflow/control/tasks/<task-id>.yaml` and `.workflow/control/checkpoints/<task-id>/<checkpoint-id>.yaml`. Keep only PID, heartbeat, temporary locks, raw Hook events, and unshared context under ignored `.workflow/delivery/runtime/`. Keep durable node documents under `.workflow/iterations/<iteration-id>/<node-id>/`; runtime snapshots never replace registered delivery artifacts. Resolve code branch and commit from `.workflow/config/workspace.yaml` and the nested code repository.
13
13
 
14
- ## Legacy Node Boundary
15
-
16
- Legacy development nodes are read-only compatibility. `inspect` remains a strictly read-only diagnosis path for historical tasks. `init`, `activate`, `checkpoint`, `finish`, and `archive-version` reject legacy task targets before mutation or Hook dispatch; `recover` refuses to materialize them and `publish` refuses to share them. New tasks use only the `development-seven-node` route, while `R00-release-request` through `R10-release-archive` remain writable.
17
-
18
14
  ## Start Or Resume
19
15
 
20
16
  1. Locate the project root and shared state root.
@@ -34,7 +34,7 @@ Keep `.workflow/delivery/runtime/` local and ignored. Persist durable stage outp
34
34
 
35
35
  `task-state.mjs` is the only runtime-state writer. Do not directly edit task JSON, checkpoint JSON, active-task markers, recovery output, or archive indexes. Formal workflow and artifact state belong to `.workflow/tools/state/state.mjs`; do not edit its YAML or JSONL streams directly.
36
36
 
37
- Legacy development nodes are read-only compatibility. `inspect` remains read-only for historical task diagnosis. `init`, `activate`, `checkpoint`, `finish`, and `archive-version` reject legacy task targets before mutation or Hook dispatch; `recover` does not hydrate them and `publish` does not share them. New task contracts use only the `development-seven-node` route, while `R00-release-request` through `R10-release-archive` remain writable.
37
+ Task contracts use the current `development-seven-node` route or the independent `R00-release-request` through `R10-release-archive` release route. `inspect` is read-only diagnosis; `init`, `activate`, `checkpoint`, `finish`, `recover`, `archive-version`, and `publish` operate only on current task contracts.
38
38
 
39
39
  When the installed Evolution CLI exists, `init`, `activate`, `checkpoint`, `finish`, and `archive-version` emit idempotent schema 1 `task.before`, `task.after`, and `task.failed` events. An approved blocking `task.before` result stops the task command before mutation. Hook dispatch failures otherwise fail open as warnings and never replace checkpoint state as the completion authority.
40
40
 
@@ -75,7 +75,7 @@ For an installed dual-repository project, `task-state.mjs` resolves `code_branch
75
75
 
76
76
  `blockers` represents current unresolved blockers. A `blocked` update replaces that list; any transition to another status clears it. Historical blocker text remains available in prior checkpoints and events.
77
77
 
78
- `next_action_owner` is required in persisted state and has exactly three values: `agent`, `user`, or `external`; only a truly absent field in a legacy snapshot defaults to `agent`, while explicit null, empty, boolean, numeric, or unknown values are invalid. `user` and `external` are zero-mutation wait states. `recover` may report shared wait state but must not hydrate local task or resume files for it. After the required input arrives, `activate` requires both `--next-action-owner agent` and a new idempotent `--next-action`; it may atomically hydrate a missing local runtime snapshot from the matching shared shard and transfer ownership in the same transition. Same-revision local and shared content must have identical semantic checksums. Evidence arrays are ordered sets: replaying an already recorded `--evidence` value does not append a duplicate.
78
+ `next_action_owner` is required in persisted state and has exactly three values: `agent`, `user`, or `external`; explicit null, empty, boolean, numeric, or unknown values are invalid. `user` and `external` are zero-mutation wait states. `recover` may report shared wait state but must not hydrate local task or resume files for it. After the required input arrives, `activate` requires both `--next-action-owner agent` and a new idempotent `--next-action`; it may atomically hydrate a missing local runtime snapshot from the matching shared shard and transfer ownership in the same transition. Same-revision local and shared content must have identical semantic checksums. Evidence arrays are ordered sets: replaying an already recorded `--evidence` value does not append a duplicate.
79
79
 
80
80
  Pass `--state-root` when worktrees must write to one shared primary state directory. Pass `--worktree` during init so the script writes a local `.workflow/delivery/runtime/active-task.json` marker pointing to that state root. Later `checkpoint`, `finish`, and `recover` commands automatically use the marker's shared state root.
81
81
 
@@ -144,15 +144,9 @@ function enforceFrontendGuard(projectRoot, input) {
144
144
  }
145
145
 
146
146
  try {
147
- const currentProjectFile = findUp(process.cwd(), path.join(".workflow", "delivery", "project.yaml"));
148
- const legacyProjectFile = currentProjectFile
149
- ? null
150
- : findUp(process.cwd(), path.join("delivery", "project.yaml"));
151
- const projectFile = currentProjectFile || legacyProjectFile;
147
+ const projectFile = findUp(process.cwd(), path.join(".workflow", "delivery", "project.yaml"));
152
148
  if (!projectFile) process.exit(0);
153
- const projectRoot = currentProjectFile
154
- ? path.dirname(path.dirname(path.dirname(projectFile)))
155
- : path.dirname(path.dirname(projectFile));
149
+ const projectRoot = path.dirname(path.dirname(path.dirname(projectFile)));
156
150
  const markerFile = path.join(projectRoot, ".workflow", "delivery", "runtime", "active-task.json");
157
151
  const marker = fs.existsSync(markerFile) ? JSON.parse(fs.readFileSync(markerFile, "utf8")) : null;
158
152
  const raw = await readStdin();
@@ -42,19 +42,13 @@ const REPEATABLE_ARGS = new Set([
42
42
  "completed-step", "blocker", "evidence", "depends-on", "acceptance", "finding-json",
43
43
  "change-set-json",
44
44
  ]);
45
- const LEGACY_DEVELOPMENT_NODE_IDS = new Set([
46
- "01-product-discovery",
47
- "02-product-definition",
48
- "03-architecture-initial",
49
- "04-experience-design",
50
- "05-architecture-reconcile",
51
- "06-platform-preparation",
52
- "07-iteration-planning",
53
- "08-test-design",
54
- "09-development",
55
- "10-change-review",
56
- "11-candidate-verification",
57
- "12-version-closeout",
45
+ const VALID_NODE_IDS = new Set([
46
+ "00-bootstrap", "01-product-shaping", "02-solution-design", "03-delivery-readiness",
47
+ "04-implementation", "05-candidate-assurance", "06-version-closeout",
48
+ "R00-release-request", "R01-version-selection", "R02-release-eligibility", "R03-material-aggregation",
49
+ "R04-release-candidate", "R05-release-verification", "R06-release-preparation", "R07-production-approval",
50
+ "R08-production-deployment", "R09-production-verification", "R10-release-archive",
51
+ "development", "implementation", "planning", "test", "review",
58
52
  ]);
59
53
 
60
54
  function parseArgs(argv) {
@@ -83,10 +77,8 @@ function requireValue(args, key) {
83
77
  return value;
84
78
  }
85
79
 
86
- function assertNotLegacyDevelopmentNode(node) {
87
- if (LEGACY_DEVELOPMENT_NODE_IDS.has(node)) {
88
- throw new Error(`Legacy workflow node is read-only: ${node}`);
89
- }
80
+ function assertCurrentNode(node) {
81
+ if (!VALID_NODE_IDS.has(node)) throw new Error(`Unsupported workflow node: ${node}`);
90
82
  return node;
91
83
  }
92
84
 
@@ -720,7 +712,7 @@ function repairChangesSinceBaseline(workflowRoot, baseline) {
720
712
  }
721
713
 
722
714
  function writeSharedTask(runtime, workflowRoot, task) {
723
- assertNotLegacyDevelopmentNode(task.node);
715
+ assertCurrentNode(task.node);
724
716
  const sharedFile = sharedTaskFile(workflowRoot, task.task_id);
725
717
  let previousRevision = 0;
726
718
  if (fs.existsSync(sharedFile)) {
@@ -786,25 +778,15 @@ function normalizeTask(task) {
786
778
  task[key] = [...new Set(task[key])];
787
779
  }
788
780
  task.sequence = parseSequence(task.sequence);
789
- task.next_action_owner = parseNextActionOwner(task.next_action_owner);
790
- task.responsibility = task.responsibility || task.skill || "agent";
781
+ task.next_action_owner = parseNextActionOwner(task.next_action_owner, undefined);
782
+ if (!task.responsibility) throw new Error(`Task ${task.task_id || "unknown"} is missing responsibility.`);
791
783
  task.derived_from = task.derived_from || null;
792
784
  task.supersedes = task.supersedes || null;
793
- if (!Array.isArray(task.attempts)) {
794
- const legacyNumber = Number(task.attempt || 0);
795
- task.attempts = legacyNumber > 0 && (task.started_at || task.status !== "queued")
796
- ? [normalizeAttempt(task.task_id, {
797
- number: legacyNumber,
798
- status: projectedTaskStatus(task.status, "attempt"),
799
- started_at: task.started_at || task.created_at || null,
800
- finished_at: CLOSED_STATUS.has(task.status) ? task.finished_at || task.updated_at || null : null,
801
- }, legacyNumber)]
802
- : [];
803
- } else {
804
- task.attempts = task.attempts.map((attempt, index) => normalizeAttempt(task.task_id, attempt, index + 1));
785
+ if (Object.prototype.hasOwnProperty.call(task, "attempt") || !Array.isArray(task.attempts)) {
786
+ throw new Error(`Task ${task.task_id || "unknown"} must use canonical attempts.`);
805
787
  }
788
+ task.attempts = task.attempts.map((attempt, index) => normalizeAttempt(task.task_id, attempt, index + 1));
806
789
  task.change_sets = Array.isArray(task.change_sets) ? task.change_sets.map((item) => normalizeChangeSet(item)) : [];
807
- task.attempt = task.attempts.at(-1)?.number || 0;
808
790
  return task;
809
791
  }
810
792
 
@@ -852,7 +834,6 @@ function startAttempt(task, phase, timestamp) {
852
834
  time_entries: [],
853
835
  }, number);
854
836
  task.attempts.push(attempt);
855
- task.attempt = number;
856
837
  return attempt;
857
838
  }
858
839
 
@@ -1379,7 +1360,7 @@ function summarizeIteration(tasks, iterationId) {
1379
1360
  function commandInit(args) {
1380
1361
  const taskId = sanitizeId(requireValue(args, "task-id"));
1381
1362
  const iterationId = sanitizeId(requireValue(args, "iteration-id"));
1382
- const node = assertNotLegacyDevelopmentNode(requireValue(args, "node"));
1363
+ const node = assertCurrentNode(requireValue(args, "node"));
1383
1364
  const { projectRoot, stateRoot, workflowRoot, runtime } = statePaths(args);
1384
1365
  return withTaskLock(runtime, `version.${iterationId}`, () => withTaskLock(runtime, taskId, () => {
1385
1366
  if (fs.existsSync(archivedTaskIndexFile(runtime, taskId))) {
@@ -1424,6 +1405,7 @@ function commandInit(args) {
1424
1405
  const candidateContract = normalizeTask({
1425
1406
  ...(existing || {}),
1426
1407
  ...contractFields,
1408
+ attempts: existing?.attempts || [],
1427
1409
  next_action_owner: existing?.next_action_owner || "agent",
1428
1410
  });
1429
1411
  if (existing) {
@@ -1459,7 +1441,6 @@ function commandInit(args) {
1459
1441
  ...contractFields,
1460
1442
  status,
1461
1443
  sequence: parseSequence(args.sequence ?? existing?.sequence),
1462
- attempt: 0,
1463
1444
  attempts: [],
1464
1445
  change_sets: [],
1465
1446
  input_artifacts: args["input-artifact"] || existing?.input_artifacts || [],
@@ -1486,7 +1467,7 @@ function commandInit(args) {
1486
1467
  clearTaskMarker(task);
1487
1468
  appendEvent(runtime, taskId, "task-initialized", {
1488
1469
  status: task.status,
1489
- attempt: task.attempt,
1470
+ attempt_count: task.attempts.length,
1490
1471
  derived_from: task.derived_from,
1491
1472
  supersedes: task.supersedes,
1492
1473
  });
@@ -1557,7 +1538,6 @@ function commandActivate(args) {
1557
1538
  phase.status = "active";
1558
1539
  phase.finished_at = null;
1559
1540
  }
1560
- task.attempt = attempt.number;
1561
1541
  task.started_at ||= timestamp;
1562
1542
  task.updated_at = timestamp;
1563
1543
  task.finished_at = null;
@@ -1573,7 +1553,7 @@ function commandActivate(args) {
1573
1553
  });
1574
1554
  appendEvent(runtime, taskId, "task-activated", {
1575
1555
  status,
1576
- attempt: task.attempt,
1556
+ attempt_count: task.attempts.length,
1577
1557
  attempt_id: attempt.attempt_id,
1578
1558
  phase: attempt.phases.at(-1)?.phase || null,
1579
1559
  new_attempt: newAttempt,
@@ -1627,7 +1607,7 @@ function commandUpdate(args, finish = false) {
1627
1607
  writeResume(runtime, task);
1628
1608
  appendEvent(runtime, taskId, finish ? "task-finished" : "task-checkpointed", {
1629
1609
  status,
1630
- attempt: attempt.number,
1610
+ attempt_number: attempt.number,
1631
1611
  attempt_id: attempt.attempt_id,
1632
1612
  phase: attempt.phases.at(-1)?.phase || null,
1633
1613
  });
@@ -1696,7 +1676,7 @@ function commandArchiveVersion(args) {
1696
1676
  .map((name) => normalizeTask(JSON.parse(fs.readFileSync(path.join(tasksDir, name), "utf8"))))
1697
1677
  .filter((task) => task.iteration_id === iterationId);
1698
1678
  if (!tasks.length) throw new Error(`No tasks found for version: ${iterationId}`);
1699
- for (const task of tasks) assertNotLegacyDevelopmentNode(task.node);
1679
+ for (const task of tasks) assertCurrentNode(task.node);
1700
1680
  const sharedById = new Map(readSharedTasks(workflowRoot, stateRoot).map((task) => [task.task_id, task]));
1701
1681
  for (const task of tasks) {
1702
1682
  const shared = sharedById.get(task.task_id);
@@ -1795,7 +1775,7 @@ function commandRecover(args) {
1795
1775
  const reportableTasks = selectedTasks
1796
1776
  .filter((task) => !requested || task.task_id === requested)
1797
1777
  .filter((task) => includeClosed || !RECOVERY_COMPLETE_STATUS.has(task.status));
1798
- for (const task of reportableTasks) assertNotLegacyDevelopmentNode(task.node);
1778
+ for (const task of reportableTasks) assertCurrentNode(task.node);
1799
1779
  for (const task of reportableTasks) {
1800
1780
  if (missingLocalTaskIds.has(task.task_id)) atomicWrite(taskFile(runtime, task.task_id), task);
1801
1781
  }
@@ -1853,7 +1833,7 @@ function commandPublish(args) {
1853
1833
  const checkpointDirectory = path.join(workflowRoot, "control", "checkpoints", taskId);
1854
1834
  if (!fs.existsSync(sharedFile)) throw new Error(`Shared task state not found: ${taskId}`);
1855
1835
  const shared = JSON.parse(fs.readFileSync(sharedFile, "utf8"));
1856
- assertNotLegacyDevelopmentNode(shared.node);
1836
+ assertCurrentNode(shared.node);
1857
1837
 
1858
1838
  const repository = gitState(stateRoot);
1859
1839
  if (!repository.repository || path.resolve(repository.root).toLowerCase() !== path.resolve(stateRoot).toLowerCase()) {
@@ -1978,7 +1958,7 @@ function assertWritableEvolutionTarget(args, command) {
1978
1958
  if (!new Set(["init", "activate", "checkpoint", "finish", "archive-version"]).has(command)) return;
1979
1959
  if (command === "init") {
1980
1960
  const node = args.node && args.node !== true ? String(args.node) : null;
1981
- if (node) assertNotLegacyDevelopmentNode(node);
1961
+ if (node) assertCurrentNode(node);
1982
1962
  return;
1983
1963
  }
1984
1964
  const { runtime, workflowRoot, stateRoot } = statePaths(args);
@@ -1990,14 +1970,14 @@ function assertWritableEvolutionTarget(args, command) {
1990
1970
  if (!iterationId || !fs.existsSync(tasksDir)) return;
1991
1971
  for (const name of fs.readdirSync(tasksDir).filter((item) => item.endsWith(".json"))) {
1992
1972
  const task = normalizeTask(JSON.parse(fs.readFileSync(path.join(tasksDir, name), "utf8")));
1993
- if (task.iteration_id === iterationId) assertNotLegacyDevelopmentNode(task.node);
1973
+ if (task.iteration_id === iterationId) assertCurrentNode(task.node);
1994
1974
  }
1995
1975
  return;
1996
1976
  }
1997
1977
  const taskId = args["task-id"] && args["task-id"] !== true ? sanitizeId(args["task-id"]) : null;
1998
1978
  if (!taskId) return;
1999
1979
  const task = taskSources(runtime, workflowRoot, stateRoot, taskId).task;
2000
- if (task) assertNotLegacyDevelopmentNode(task.node);
1980
+ if (task) assertCurrentNode(task.node);
2001
1981
  }
2002
1982
 
2003
1983
  function dispatchTaskEvolution(context, phase, result = null, error = null) {