🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

coding-agent-harness

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

coding-agent-harness - npm Package Compare versions

Comparing version
1.1.0
to
1.1.1
+37
dist/application/module/module-governance.mjs
import { beginGovernanceSync, commitGovernanceSync, governanceRelativePaths, releaseGovernanceSync, } from "../../lib/governance-sync.mjs";
export function moduleGovernanceRelativePaths(changes) {
return governanceRelativePaths(changes);
}
export function commitModuleGovernance(target, changes, { operation, dryRun, message, allowDirtyWriteScope = false, }) {
const allowedRelativePaths = governanceRelativePaths(changes);
const context = beginGovernanceSync(target, {
operation,
dryRun,
allowDirtyWorktree: true,
allowedRelativePaths,
allowDirtyWriteScope,
});
try {
return commitGovernanceSync(context, allowedRelativePaths, { message });
}
finally {
releaseGovernanceSync(context);
}
}
export function runModuleGovernanceTransaction(target, plannedChanges, { operation, dryRun, message, allowDirtyWriteScope = false, run, }) {
const context = beginGovernanceSync(target, {
operation,
dryRun,
allowDirtyWorktree: true,
allowedRelativePaths: governanceRelativePaths(plannedChanges),
allowDirtyWriteScope,
});
try {
const result = run();
const commit = commitGovernanceSync(context, governanceRelativePaths(result.changes), { message });
return { ...result, governance: { commit } };
}
finally {
releaseGovernanceSync(context);
}
}
import { normalizeTarget } from "../../lib/core-shared.mjs";
import { createTask, confirmTaskReview, updateTaskLifecycle } from "../../lib/task-lifecycle.mjs";
import { createLessonSedimentationTask } from "../../lib/task-lesson-sedimentation.mjs";
import { archiveTask, deleteTask, reopenTask, supersedeTask } from "../../lib/task-tombstone-commands.mjs";
import { createScannerTaskRepository } from "../../lib/task-repository.mjs";
import { buildTaskSemanticProjection } from "../../lib/task-semantic-projection.mjs";
export class TaskOperationError extends Error {
status;
code;
details;
recovery;
payload;
constructor(result) {
super(result.reason);
this.name = "TaskOperationError";
this.status = result.status;
this.code = result.code;
this.details = result.details;
this.recovery = result.recovery;
this.payload = result.payload;
}
}
export function createTaskOperations(targetInput = ".", options = {}) {
const rawTargetInput = targetInput || ".";
const target = normalizeTarget(rawTargetInput);
const repository = options.repository || createScannerTaskRepository(target);
const targetRoot = target.projectRoot;
return {
create(input) {
const { taskId, targetInput: createTargetInput, ...createOptions } = input;
return runOperation(() => createTask(createTargetInput || rawTargetInput, taskId, createOptions));
},
updateLifecycle(input) {
const { taskId, ...lifecycleOptions } = input;
if (lifecycleOptions.event === "task-complete" || lifecycleOptions.state === "done") {
return this.complete({ taskId, message: lifecycleOptions.message, evidence: lifecycleOptions.evidence });
}
return runOperation(() => updateTaskLifecycle(targetRoot, taskId, lifecycleOptions));
},
start(input) {
return this.updateLifecycle({ ...input, event: "task-start", state: "in_progress" });
},
review(input) {
return this.updateLifecycle({ ...input, event: "task-review", state: "review" });
},
complete(input) {
const task = getOperationTask(repository, input.taskId);
if (!task.success)
return task;
const blocked = taskCompleteBlock(task.data);
if (blocked)
return blocked;
return runOperation(() => updateTaskLifecycle(targetRoot, input.taskId, {
event: "task-complete",
state: "done",
message: input.message,
evidence: input.evidence,
}));
},
confirmReview(input) {
const task = getOperationTask(repository, input.taskId);
if (!task.success)
return task;
const blocked = reviewConfirmationBlock(task.data);
if (blocked)
return blocked;
const { taskId, ...reviewOptions } = input;
return runOperation(() => confirmTaskReview(targetRoot, taskId, reviewOptions));
},
delete(input) {
return runOperation(() => deleteTask(targetRoot, input.taskId, {
hard: input.hard === true,
reason: input.reason || "",
deletedBy: input.deletedBy || "",
confirm: input.confirm || "",
allowOpenFindings: input.allowOpenFindings === true,
}));
},
archive(input) {
return runOperation(() => archiveTask(targetRoot, input.taskId, {
reason: input.reason || "",
archivedBy: input.archivedBy || "",
archiveFields: input.archiveFields || {},
}));
},
supersede(input) {
return runOperation(() => supersedeTask(targetRoot, input.taskId, {
by: input.by || "",
reason: input.reason || "",
deletedBy: input.deletedBy || "",
confirm: input.confirm || "",
allowOpenFindings: input.allowOpenFindings === true,
}));
},
reopen(input) {
return runOperation(() => reopenTask(targetRoot, input.taskId, {
reason: input.reason || "",
}));
},
lessonSediment(input) {
const task = getOperationTask(repository, input.taskId);
if (!task.success)
return task;
if (!String(input.candidateId || "").trim())
return failure("Missing lesson candidate id", { status: 400 });
return runOperation(() => createLessonSedimentationTask(targetRoot, input.taskId, input.candidateId, {
dryRun: input.dryRun === true,
title: input.title || "",
deferCommit: input.deferCommit === true,
allowDirtyRelativePaths: input.allowDirtyRelativePaths || [],
}));
},
};
}
export function unwrapTaskOperation(result) {
if (result.success)
return result.data;
throw new TaskOperationError(result);
}
export function taskOperationFailurePayload(result) {
return result.payload;
}
function runOperation(operation) {
try {
return { success: true, status: 200, data: operation() };
}
catch (error) {
return failureFromError(error);
}
}
function getOperationTask(repository, taskId) {
if (!String(taskId || "").trim())
return failure("Missing task id", { status: 400 });
try {
return { success: true, status: 200, data: repository.get({ id: taskId }) };
}
catch (error) {
const reason = errorMessage(error);
return failure(reason, { status: reason.startsWith("Task not found") ? 404 : 400 });
}
}
function reviewConfirmationBlock(task) {
const projection = taskOperationProjection(task);
const lifecycle = projection.taskLifecycleProjection;
const reviewView = projection.reviewWorkbenchQueueView;
if (reviewView.confirmed) {
return failure("Review is already confirmed.", { status: 409, payload: { reviewStatus: lifecycle.reviewStatus || "confirmed", taskId: task.id || "" } });
}
if (!reviewView.humanConfirmable) {
return failure("Review completion is only available for tasks in the review queue.", {
status: 409,
payload: {
reviewQueueState: lifecycle.reviewQueueState || "unknown",
taskQueues: reviewView.queues,
queueReasons: Array.isArray(task.queueReasons) ? task.queueReasons : [],
repairPrompt: task.repairPrompt || "",
reviewStatus: lifecycle.reviewStatus || "unknown",
taskId: task.id || "",
},
});
}
return null;
}
function taskCompleteBlock(task) {
const projection = taskOperationProjection(task);
const lifecycle = projection.taskLifecycleProjection;
const reviewView = projection.reviewWorkbenchQueueView;
const budget = String(task.budget || "");
const state = lifecycle.state || "unknown";
if (budget === "simple")
return null;
if (reviewView.hasPendingLessonWork) {
return closeoutFailure(task, "Lesson candidate promotion or sedimentation work must be resolved before closeout.");
}
if (budget !== "simple" && state !== "review") {
return closeoutFailure(task, `task-complete for ${budget || "standard"} tasks requires current state review. Run task-review first.`);
}
const blockingRisks = openBlockingReviewRisks(task);
if (blockingRisks.length > 0) {
const ids = blockingRisks.map((risk) => risk.id || risk.severity || "blocking-risk").join(", ");
return closeoutFailure(task, `Open blocking review findings must be closed before task-complete: ${ids}`);
}
if (!reviewView.confirmed) {
return closeoutFailure(task, "Human review must be confirmed before task-complete. Confirm it from the local Dashboard workbench first.");
}
if (lifecycle.closeoutStatus === "closed") {
return closeoutFailure(task, "Task closeout is already closed.");
}
const lessonStatus = String(task.lessonCandidateStatus || "");
if (budget !== "simple" && !["no-candidate-accepted", "promoted", "rejected"].includes(lessonStatus)) {
return closeoutFailure(task, `Lesson candidate decision must be complete before task-complete; current status is ${lessonStatus || "missing"}.`);
}
return null;
}
function taskHasPendingLessonWork(task) {
const queues = taskQueues(task);
const candidateRows = Array.isArray(task.lessonCandidateRows) ? task.lessonCandidateRows : [];
return queues.includes("lessons") ||
task.lessonCandidateStatus === "needs-promotion" ||
task.lessonCandidatePromotionState === "queued" ||
candidateRows.some((candidate) => ["ready-for-review", "needs-promotion"].includes(String(candidate.status || "")));
}
function openBlockingReviewRisks(task) {
return (task.risks || []).filter((risk) => reviewBoolean(risk.open) !== "no" && (reviewBoolean(risk.blocksRelease) === "yes" || ["P0", "P1", "P2"].includes(String(risk.severity))));
}
function closeoutFailure(task, reason) {
const projection = taskOperationProjection(task);
const lifecycle = projection.taskLifecycleProjection;
return failure(reason, {
status: 409,
payload: {
closeoutStatus: lifecycle.closeoutStatus || "unknown",
lifecycleState: lifecycle.lifecycleState || "unknown",
reviewStatus: lifecycle.reviewStatus || "unknown",
taskQueues: projection.reviewWorkbenchQueueView.queues,
lessonCandidateStatus: task.lessonCandidateStatus || "unknown",
lessonCandidatePromotionState: task.lessonCandidatePromotionState || "unknown",
taskId: task.id || "",
},
});
}
function failureFromError(error) {
const source = isRecord(error) ? error : {};
return failure(errorMessage(error), {
status: typeof source.status === "number" ? source.status : 400,
code: typeof source.code === "string" ? source.code : undefined,
details: source.details,
recovery: Array.isArray(source.recovery) ? source.recovery : undefined,
});
}
function failure(reason, { status = 400, code, details, recovery, payload = {} } = {}) {
const nextPayload = { error: reason, ...payload };
if (code)
nextPayload.code = code;
if (details)
nextPayload.details = details;
if (recovery && recovery.length > 0)
nextPayload.recovery = recovery;
return { success: false, status, reason, payload: nextPayload, code, details, recovery };
}
function taskQueues(task) {
return taskOperationProjection(task).reviewWorkbenchQueueView.queues;
}
function taskOperationProjection(task) {
if (task.semanticProjection)
return task.semanticProjection;
if (task.taskLifecycleProjection && task.reviewWorkbenchQueueView) {
return {
taskLifecycleProjection: task.taskLifecycleProjection,
reviewWorkbenchQueueView: task.reviewWorkbenchQueueView,
dashboardTaskView: buildTaskSemanticProjection(task).dashboardTaskView,
};
}
return buildTaskSemanticProjection(task);
}
function reviewBoolean(value) {
if (value === true)
return "yes";
if (value === false)
return "no";
const normalized = String(value || "").trim().toLowerCase();
if (["yes", "y", "true", "open"].includes(normalized))
return "yes";
if (["no", "n", "false", "closed"].includes(normalized))
return "no";
return "";
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error || "unknown error");
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
import { beginGovernanceSync, commitGovernanceSync, releaseGovernanceSync, } from "../../lib/governance-sync.mjs";
import { finalizeDeferredTaskReviewConfirmation } from "../../lib/task-lifecycle.mjs";
export function commitWorkbenchBatch(target, allowedPaths, { operation, message }) {
const paths = uniqueValues(allowedPaths || []);
const context = beginGovernanceSync(target, {
operation,
allowDirtyWorktree: true,
allowedRelativePaths: paths,
allowDirtyWriteScope: true,
});
try {
return commitGovernanceSync(context, paths, { message });
}
finally {
releaseGovernanceSync(context);
}
}
export function finalizeWorkbenchReviewConfirmation(target, taskId, { commitSha }) {
if (!commitSha)
return;
finalizeDeferredTaskReviewConfirmation(target.projectRoot, taskId, { commitSha });
}
function uniqueValues(values) {
const seen = new Set();
const result = [];
for (const value of values) {
if (seen.has(value))
continue;
seen.add(value);
result.push(value);
}
return result;
}
export const datePrefix = /^\d{4}-\d{2}-\d{2}-/;
export function todayDate() {
return localDate();
}
export function localDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function nowTimestamp() {
return new Date().toISOString().replace("T", " ").slice(0, 16);
}
import fs from "node:fs";
import path from "node:path";
export function readFileSafe(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
}
catch {
return "";
}
}
export function readJsonSafe(filePath, fallback = null, { onError } = {}) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
catch (error) {
if (typeof onError === "function")
onError(error);
return fallback;
}
}
export function walkFiles(root, options = {}) {
const results = [];
if (!fs.existsSync(root))
return results;
const dirFilter = typeof options.dirFilter === "function" ? options.dirFilter : () => true;
function walk(dir) {
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if ([".git", "node_modules", "tmp"].includes(entry))
continue;
if (!dirFilter(entry, full))
continue;
walk(full);
}
else {
results.push(full);
}
}
}
walk(root);
return results;
}
import fs from "node:fs";
export function readHarnessManifest(manifestPath) {
if (!fs.existsSync(manifestPath))
return null;
const manifest = { version: 2, locale: "en-US", capabilities: [], structure: {} };
let section = "";
let inModuleItems = false;
let currentModuleKey = "";
let currentModuleListField = "";
for (const rawLine of fs.readFileSync(manifestPath, "utf8").split(/\r?\n/)) {
const line = rawLine.replace(/\s+#.*$/, "");
if (!line.trim())
continue;
const top = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (top) {
section = top[1];
inModuleItems = false;
currentModuleKey = "";
currentModuleListField = "";
if (section === "version")
manifest.version = Number(top[2]) || 2;
else if (section === "locale")
manifest.locale = top[2] || "en-US";
else if (section === "modules")
manifest.modules = { items: {} };
else if (section !== "structure" && section !== "capabilities")
manifest[section] = top[2];
continue;
}
const listItem = line.match(/^\s*-\s*(.+)$/);
if (section === "capabilities" && listItem) {
manifest.capabilities.push(listItem[1].trim());
continue;
}
const nested = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.+)$/);
if (section === "structure" && nested)
manifest.structure[nested[1]] = nested[2].trim();
if (section === "modules") {
if (!manifest.modules)
manifest.modules = { items: {} };
const moduleTop = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (moduleTop) {
currentModuleKey = "";
currentModuleListField = "";
if (moduleTop[1] === "items")
inModuleItems = true;
else if (moduleTop[1] === "schema")
manifest.modules.schema = moduleTop[2].trim();
else if (moduleTop[1] === "generatedView")
manifest.modules.generatedView = moduleTop[2].trim();
else
manifest.modules[moduleTop[1]] = moduleTop[2].trim();
continue;
}
const moduleItem = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (inModuleItems && moduleItem) {
currentModuleKey = moduleItem[1];
currentModuleListField = "";
if (!manifest.modules.items[currentModuleKey])
manifest.modules.items[currentModuleKey] = {};
continue;
}
const moduleField = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (inModuleItems && currentModuleKey && moduleField) {
const field = moduleField[1];
const raw = moduleField[2].trim();
if (["scope", "shared", "dependsOn"].includes(field)) {
manifest.modules.items[currentModuleKey][field] = raw === "[]" ? [] : raw ? [raw] : [];
currentModuleListField = field;
}
else {
manifest.modules.items[currentModuleKey][field] = raw;
currentModuleListField = "";
}
continue;
}
const moduleListItem = line.match(/^ -\s*(.+)$/);
if (inModuleItems && currentModuleKey && currentModuleListField && moduleListItem) {
const existing = manifest.modules.items[currentModuleKey][currentModuleListField];
manifest.modules.items[currentModuleKey][currentModuleListField] = [
...(Array.isArray(existing) ? existing : []),
moduleListItem[1].trim(),
];
}
}
}
if (!manifest.structure.harnessRoot && manifest.harnessRoot)
manifest.structure.harnessRoot = manifest.harnessRoot;
if (!manifest.structure.planningRoot && manifest.harnessRoot)
manifest.structure.planningRoot = `${manifest.harnessRoot}/planning`;
return manifest;
}
export function renderHarnessManifest({ locale, capabilities, structure = null, modules = null }) {
const manifestStructure = structure || {
harnessRoot: "coding-agent-harness",
planningRoot: "coding-agent-harness/planning",
tasksRoot: "coding-agent-harness/planning/tasks",
modulesRoot: "coding-agent-harness/planning/modules",
externalRoot: "coding-agent-harness/planning/external",
governanceRoot: "coding-agent-harness/governance",
generatedRoot: "coding-agent-harness/governance/generated",
};
const lines = [
"version: 2",
`locale: ${locale}`,
"capabilities:",
...capabilities.map((capability) => ` - ${capability}`),
"structure:",
...Object.entries(manifestStructure).map(([key, value]) => ` ${key}: ${value}`),
];
if (modules && (modules.schema || modules.generatedView || Object.keys(modules.items || {}).length > 0)) {
lines.push("modules:");
if (modules.schema)
lines.push(` schema: ${yamlScalar(modules.schema)}`);
if (modules.generatedView)
lines.push(` generatedView: ${yamlScalar(modules.generatedView)}`);
lines.push(" items:");
for (const [key, module] of Object.entries(modules.items || {}).sort(([left], [right]) => left.localeCompare(right))) {
lines.push(` ${key}:`);
for (const field of ["title", "prefix", "status", "branch", "owner", "currentStep", "scope", "shared", "dependsOn", "plan", "brief", "updated"]) {
const value = module[field];
if (Array.isArray(value)) {
lines.push(` ${field}:${value.length ? "" : " []"}`);
for (const item of value)
lines.push(` - ${yamlScalar(String(item))}`);
}
else if (value !== undefined && value !== null && String(value) !== "") {
lines.push(` ${field}: ${yamlScalar(String(value))}`);
}
}
}
}
return `${lines.join("\n")}\n`;
}
export function assertRenderableHarnessManifest(manifest) {
if (!manifest)
return;
const allowed = new Set(["version", "locale", "capabilities", "structure", "modules", "harnessRoot"]);
const unknown = Object.keys(manifest).filter((key) => !allowed.has(key));
if (unknown.length > 0)
throw new Error(`Cannot rewrite harness.yaml with unknown top-level fields: ${unknown.join(", ")}`);
}
function yamlScalar(value) {
const raw = String(value || "");
if (!raw)
return "''";
if (/[:#\n\r]|^\s|\s$|^(?:true|false|null|\d+(?:\.\d+)?)$/i.test(raw))
return JSON.stringify(raw);
return raw;
}
import path from "node:path";
export function toPosix(value) {
return String(value).split(path.sep).join("/");
}
export function prefixedPath(target, filePath) {
return `TARGET:${toPosix(path.relative(target.projectRoot, filePath))}`;
}
export function slug(value) {
return String(value || "item")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "item";
}
export function sanitizeText(value) {
return String(value ?? "")
.replace(/file:\/\/\/[^\s)"'`<>\]]+/g, "LOCAL_FILE_URL_REDACTED")
.replaceAll("file://", "LOCAL_FILE_URL_REDACTED")
.replace(/\/Users\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/Volumes\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/(?:private\/)?tmp\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/var\/folders\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/home\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/[A-Za-z]:\\[^\s)"'`<>\]]+(?:\\[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED");
}
export function sanitizeDeep(value) {
if (typeof value === "string")
return sanitizeText(value);
if (Array.isArray(value))
return value.map(sanitizeDeep);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeDeep(entry)]));
}
return value;
}
export function titleFromMarkdown(content, fallback) {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : fallback;
}
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import { beginGovernanceSync, commitGovernanceSync, GovernanceSyncError, inspectGit, releaseGovernanceSync } from "./governance-sync.mjs";
import { normalizeTarget, toPosix } from "./core-shared.mjs";
export function createGovernanceHarnessTransaction(targetInput = ".") {
const target = normalizeTransactionTarget(targetInput);
return {
plan(changeSet) {
const git = inspectGit(target.projectRoot);
const allowedPaths = normalizeAllowedPaths(target, pathsFromChangeSet(changeSet));
const generatedSurfaces = generatedSurfaceNames(changeSet.generatedSurfaces || []);
return {
changeSet,
operation: changeSet.operation || "harness-transaction",
dryRun: changeSet.dryRun === true,
targetRoot: target.projectRoot,
allowedPaths,
generatedSurfaces,
git,
conflicts: detectPlanConflicts(git, allowedPaths, changeSet.commit),
};
},
apply(plan) {
const lockPath = governanceLockPath(target);
let release = releaseSummary(lockPath, false);
let context = null;
let allowedPaths = [...plan.allowedPaths];
let generatedSurfaces = [...plan.generatedSurfaces];
const writes = writePaths(target, plan.changeSet.writes || []);
const deletes = deletePaths(target, plan.changeSet.deletes || []);
try {
const initialEntries = fingerprintEntries(target, transactionScopeEntries(target));
context = beginGovernanceSync(target, {
operation: plan.operation,
dryRun: plan.dryRun,
allowDirtyWorktree: plan.changeSet.commit?.allowDirtyWorktree === true,
allowDirtyWriteScope: plan.changeSet.commit?.allowDirtyWriteScope === true,
allowedRelativePaths: allowedPaths,
});
applyDeclarativeChanges(target, plan);
const mutation = plan.dryRun ? {} : plan.changeSet.apply?.({ target, plan }) || {};
const mutationGeneratedSurfaces = mutation.generatedSurfaces || [];
const commitOptions = { ...(plan.changeSet.commit || {}), ...(mutation.commit || {}) };
const mutationAllowedPaths = normalizeAllowedPaths(target, [...(mutation.allowedPaths || []), ...generatedSurfacePaths(mutationGeneratedSurfaces)]);
assertLateAllowedPathsAreSafe(plan, mutationAllowedPaths, commitOptions);
allowedPaths = normalizeAllowedPaths(target, [...allowedPaths, ...mutationAllowedPaths]);
generatedSurfaces = [...new Set([...generatedSurfaces, ...generatedSurfaceNames(mutationGeneratedSurfaces)])].sort();
assertNoOutOfScopeTransactionChanges(target, allowedPaths, initialEntries);
const commit = commitOptions.defer === true
? { committed: false, reason: "deferred", allowedPaths }
: commitGovernanceSync(context, allowedPaths, { message: commitOptions.message || "chore(harness): sync governance state" });
release = releaseGovernanceContext(context, lockPath);
return {
success: true,
operation: plan.operation,
dryRun: plan.dryRun,
allowedPaths,
generatedSurfaces,
git: plan.git,
writes,
deletes,
commit,
release,
};
}
catch (error) {
release = releaseGovernanceContext(context, lockPath);
return {
success: false,
operation: plan.operation,
dryRun: plan.dryRun,
allowedPaths,
generatedSurfaces,
git: plan.git,
writes,
deletes,
commit: { committed: false, reason: "failed", allowedPaths },
release,
error: summarizeError(error),
cause: error,
};
}
},
};
}
export function assertTransactionSucceeded(result) {
if (result.success)
return;
throw result.cause instanceof Error ? result.cause : new Error(result.error.message);
}
function normalizeTransactionTarget(targetInput) {
if (typeof targetInput === "string")
return normalizeTarget(targetInput);
return targetInput;
}
function pathsFromChangeSet(changeSet) {
return [
...(changeSet.allowedPaths || []),
...(changeSet.writes || []).map((write) => write.path),
...(changeSet.deletes || []).map((deleted) => deleted.path),
...generatedSurfacePaths(changeSet.generatedSurfaces || []),
];
}
function generatedSurfaceNames(surfaces) {
return [...new Set(surfaces.map((surface) => typeof surface === "string" ? surface : surface.surface).filter(Boolean))].sort();
}
function generatedSurfacePaths(surfaces) {
return surfaces.flatMap((surface) => typeof surface === "string" ? [] : surface.paths || []);
}
function normalizeAllowedPaths(target, rawPaths) {
return [...new Set(rawPaths.map((rawPath) => relativeTargetPath(target, rawPath)))].sort();
}
function detectPlanConflicts(git, allowedPaths, commit) {
if (!git.inGit || git.entries.length === 0)
return [];
if (commit?.allowDirtyWorktree !== true)
return git.entries.map((entry) => `dirty git entry: ${entry.path}`);
const allowed = new Set(allowedPaths);
const conflicts = [];
if (commit.allowDirtyWriteScope !== true) {
conflicts.push(...git.entries.filter((entry) => allowed.has(entry.path)).map((entry) => `dirty allowed path: ${entry.path}`));
}
conflicts.push(...git.entries.filter((entry) => entry.index !== " " && entry.index !== "?" && !allowed.has(entry.path)).map((entry) => `staged outside transaction scope: ${entry.path}`));
return conflicts;
}
function applyDeclarativeChanges(target, plan) {
if (plan.dryRun)
return;
for (const write of plan.changeSet.writes || []) {
const destination = absoluteTargetPath(target, write.path);
fs.mkdirSync(path.dirname(destination), { recursive: true });
if (typeof write.content === "string") {
fs.writeFileSync(destination, write.content, write.encoding || "utf8");
}
else {
fs.writeFileSync(destination, write.content);
}
if (write.mode !== undefined)
fs.chmodSync(destination, write.mode);
}
for (const deleted of plan.changeSet.deletes || []) {
const destination = absoluteTargetPath(target, deleted.path);
fs.rmSync(destination, { recursive: deleted.recursive === true, force: deleted.force !== false });
}
}
function writePaths(target, writes) {
return writes.map((write) => relativeTargetPath(target, write.path));
}
function deletePaths(target, deletes) {
return deletes.map((deleted) => relativeTargetPath(target, deleted.path));
}
function relativeTargetPath(target, rawPath) {
return transactionPath(target, rawPath).relative;
}
function absoluteTargetPath(target, rawPath) {
return transactionPath(target, rawPath).absolute;
}
function transactionPath(target, rawPath) {
const stripped = String(rawPath || "").replace(/^TARGET:/, "").replace(/^\.\//, "");
if (!stripped || stripped === ".")
throw transactionPathError(target, rawPath);
const targetRoot = path.resolve(target.projectRoot);
const absolute = path.isAbsolute(stripped) ? path.resolve(stripped) : path.resolve(targetRoot, stripped);
const relative = path.relative(targetRoot, absolute);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
throw transactionPathError(target, rawPath);
const realTargetRoot = fs.realpathSync(targetRoot);
const resolved = resolveExistingAncestorPath(absolute);
const realRelative = path.relative(realTargetRoot, resolved);
if (!realRelative || realRelative.startsWith("..") || path.isAbsolute(realRelative))
throw transactionPathError(target, rawPath);
return { absolute, relative: toPosix(relative) };
}
function assertLateAllowedPathsAreSafe(plan, mutationAllowedPaths, commitOptions) {
if (commitOptions.allowDirtyWorktree !== true && commitOptions.defer !== true)
return;
const predeclared = new Set(plan.allowedPaths);
const late = mutationAllowedPaths.filter((allowedPath) => !predeclared.has(allowedPath));
if (late.length === 0)
return;
throw new GovernanceSyncError("Callback-added transaction paths must be predeclared when dirty worktree or deferred commit mode is enabled.", {
code: "transaction-late-paths-require-predeclaration",
details: { latePaths: late, predeclaredAllowedPaths: plan.allowedPaths, operation: plan.operation },
recovery: ["Add callback-discovered paths to ChangeSet.allowedPaths before planning the transaction."],
});
}
function assertNoOutOfScopeTransactionChanges(target, allowedPaths, initialEntries) {
const allowed = new Set(allowedPaths);
const initialOutside = new Map(initialEntries.filter((entry) => !allowed.has(entry.path)).map((entry) => [entry.path, entry]));
const unexpected = [];
const changed = [];
for (const entry of fingerprintEntries(target, transactionScopeEntries(target))) {
if (allowed.has(entry.path))
continue;
const initial = initialOutside.get(entry.path);
if (!initial) {
unexpected.push(entry);
}
else if (initial.raw !== entry.raw || initial.fingerprint !== entry.fingerprint) {
changed.push({ before: initial, after: entry });
}
}
if (unexpected.length === 0 && changed.length === 0)
return;
throw new GovernanceSyncError("Transaction produced changes outside the transaction write scope.", {
code: "transaction-write-scope-violation",
details: { unexpected, changed, allowedPaths },
recovery: ["Declare all transaction-owned paths before applying, or remove unintended side effects."],
});
}
function fingerprintEntries(target, entries) {
return entries.map((entry) => ({ ...entry, fingerprint: fingerprintEntry(target, entry) }));
}
function transactionScopeEntries(target) {
const git = inspectGit(target.projectRoot);
if (!git.inGit)
return fileSystemStatusEntries(target.projectRoot);
const result = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"], {
cwd: target.projectRoot,
encoding: "utf8",
});
if (result.status !== 0) {
throw new GovernanceSyncError("git status failed while inspecting transaction write scope.", {
code: "transaction-git-status-failed",
details: { stdout: result.stdout.trim(), stderr: result.stderr.trim() },
recovery: ["Inspect the Git error and retry after resolving it."],
});
}
return result.stdout
.split(/\r?\n/)
.filter(Boolean)
.map((line) => ({
index: line.slice(0, 1),
worktree: line.slice(1, 2),
path: toPosix(parseStatusPath(line.slice(3))),
raw: line,
}))
.filter((entry) => entry.path !== ".harness/locks/governance-sync.lock");
}
function fileSystemStatusEntries(root) {
const entries = [];
const visit = (directory) => {
if (!fs.existsSync(directory))
return;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
const relative = toPosix(path.relative(root, absolute));
if (relative === ".harness/locks/governance-sync.lock")
continue;
if (entry.name === ".git")
continue;
if (entry.isDirectory()) {
visit(absolute);
continue;
}
entries.push({ index: "?", worktree: "?", path: relative, raw: `?? ${relative}` });
}
};
visit(root);
return entries;
}
function fingerprintEntry(target, entry) {
const absolute = path.join(target.projectRoot, entry.path);
try {
const stat = fs.lstatSync(absolute);
if (stat.isSymbolicLink())
return `symlink:${fs.readlinkSync(absolute)}`;
if (stat.isFile())
return `file:${stat.size}:${crypto.createHash("sha256").update(new Uint8Array(fs.readFileSync(absolute))).digest("hex")}`;
if (stat.isDirectory())
return "directory";
return `${stat.mode}:${stat.size}`;
}
catch {
return "missing";
}
}
function transactionPathError(target, rawPath) {
return new GovernanceSyncError("Transaction paths must be non-empty and stay inside the transaction target.", {
code: "transaction-path-outside-target",
details: { path: rawPath, targetRoot: target.projectRoot },
recovery: ["Use a path inside the target project root."],
});
}
function parseStatusPath(value) {
const unquoted = value.replace(/^"|"$/g, "");
return unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
}
function resolveExistingAncestorPath(absolutePath) {
const missingSegments = [];
let current = path.resolve(absolutePath);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current)
break;
missingSegments.unshift(path.basename(current));
current = parent;
}
const realAncestor = fs.realpathSync(current);
return path.resolve(realAncestor, ...missingSegments);
}
function releaseGovernanceContext(context, lockPath) {
releaseGovernanceSync(context);
return releaseSummary(lockPath, true);
}
function releaseSummary(lockPath, attempted) {
return {
attempted,
completed: !fs.existsSync(lockPath),
lockPath,
};
}
function governanceLockPath(target) {
return path.join(target.projectRoot, ".harness/locks/governance-sync.lock");
}
function summarizeError(error) {
if (!(error instanceof Error))
return { name: "Error", message: String(error) };
const candidate = error;
return {
name: candidate.name || "Error",
message: candidate.message,
code: candidate.code,
details: candidate.details,
recovery: candidate.recovery,
};
}
const swimlaneColumnLabelKeys = {
active: "active",
"missing-materials": "queueMissingMaterials",
blocked: "queueBlocked",
review: "queueReview",
lessons: "queueLessons",
finalized: "state_finalized",
"soft-deleted-superseded": "queueSoftDeletedSuperseded",
planned: "planned",
};
export function buildTaskSemanticProjection(task) {
const taskQueues = normalizedTaskQueues(task);
const lifecycle = buildTaskLifecycleProjection(task, taskQueues);
const reviewWorkbenchQueueView = buildReviewWorkbenchQueueView(task, lifecycle, taskQueues);
const dashboardTaskView = buildDashboardTaskView(task, lifecycle, reviewWorkbenchQueueView);
return {
taskLifecycleProjection: lifecycle,
dashboardTaskView,
reviewWorkbenchQueueView,
};
}
export function attachTaskSemanticProjection(task) {
const semanticProjection = buildTaskSemanticProjection(task);
return {
...task,
semanticProjection,
...semanticProjection,
};
}
export function buildTaskLifecycleProjection(task, taskQueues = normalizedTaskQueues(task)) {
return {
state: stringValue(task.state, "unknown"),
lifecycleState: stringValue(task.lifecycleState, "unknown"),
reviewStatus: stringValue(task.reviewStatus, "missing"),
reviewQueueState: stringValue(task.reviewQueueState, "not-in-queue"),
closeoutStatus: stringValue(task.closeoutStatus, "missing"),
taskQueues,
materialsReady: task.materialsReady === true,
reviewSubmitted: task.reviewSubmitted === true,
lessonCandidateDecisionComplete: task.lessonCandidateDecisionComplete === true,
deletionState: stringValue(task.deletionState, "active"),
};
}
export function buildDashboardTaskView(task, lifecycle = buildTaskLifecycleProjection(task), reviewWorkbenchQueueView = buildReviewWorkbenchQueueView(task, lifecycle, lifecycle.taskQueues)) {
const needsEvidence = taskNeedsEvidence(task);
const reason = firstQueueReason(task);
const visible = taskVisibleInSwimlane(task, lifecycle, reviewWorkbenchQueueView);
const materials = buildDashboardTaskMaterialsView(task, needsEvidence);
const swimlane = buildDashboardTaskSwimlaneView(task, visible, reviewWorkbenchQueueView);
return {
visibleInSwimlane: visible,
swimlaneStage: swimlane.columnKey,
swimlane,
materials,
needsEvidence,
reasonCode: reason.code || reason.queue || (needsEvidence ? "needs-evidence" : lifecycle.reviewQueueState === "ready-to-confirm" ? "ready-to-confirm" : lifecycle.closeoutStatus === "missing" ? "needs-closeout" : ""),
reasonMessage: reason.message || "",
};
}
function buildDashboardTaskMaterialsView(task, needsEvidence = taskNeedsEvidence(task)) {
const visualMapStatus = stringValue(task.visualMapStatus, "present");
const briefSource = stringValue(task.briefSource, "standalone");
const briefReady = !briefSource || briefSource === "standalone";
const visualMapReady = !["missing", "legacy-only"].includes(visualMapStatus);
const phaseEvidenceReady = !(task.phases || []).some((phase) => ["missing", "partial"].includes(stringValue(phase.evidenceStatus, "")));
return {
briefReady,
visualMapReady,
evidenceReady: !needsEvidence && phaseEvidenceReady,
blockingReasonCodes: (task.queueReasons || []).map((reason) => stringValue(reason.code || reason.queue, "")).filter(Boolean),
};
}
function buildDashboardTaskSwimlaneView(task, visible, reviewWorkbenchQueueView) {
const taskRecord = task;
const rowKey = stringValue(taskRecord.module || taskRecord.inferredModule, "legacy-unclassified");
const columnKey = reviewWorkbenchQueueView.primaryQueue || "active";
return {
visible,
rowKey,
rowLabelKey: rowKey === "base" ? "baseModule" : rowKey === "legacy-unclassified" ? "unclassifiedModule" : "",
columnKey,
columnLabelKey: swimlaneColumnLabelKeys[columnKey] || `state_${columnKey}`,
tone: swimlaneTone(columnKey),
sortKey: stringValue(taskRecord.shortId || taskRecord.id || taskRecord.path || taskRecord.title, ""),
};
}
export function buildReviewWorkbenchQueueView(task, lifecycle = buildTaskLifecycleProjection(task), taskQueues = normalizedTaskQueues(task)) {
const hasPendingLessonWork = taskHasPendingLessonWork(task, taskQueues);
const humanConfirmable = lifecycle.reviewQueueState === "ready-to-confirm" && taskQueues.includes("review");
const confirmed = lifecycle.reviewStatus === "confirmed" || task.reviewConfirmation?.confirmed === true || taskQueues.includes("confirmed");
const finalized = confirmed || lifecycle.closeoutStatus === "closed" || taskQueues.includes("finalized");
const readyForCloseout = false;
const primaryQueue = primaryReviewQueue(taskQueues);
const reasonSummaries = normalizedQueueReasons(task);
return {
queues: taskQueues,
primaryQueue,
inQueue: lifecycle.reviewQueueState !== "not-in-queue" || taskQueues.some((queue) => !["active", "planned", "done", "unknown"].includes(queue)),
humanConfirmable,
blocked: lifecycle.reviewStatus === "blocked-open-findings" || taskQueues.includes("blocked") || blockingRiskCount(task) > 0,
needsMaterials: lifecycle.reviewQueueState === "needs-material" || taskQueues.includes("missing-materials"),
confirmed,
finalized,
hasPendingLessonWork,
readyForCloseout,
reasonCodes: reasonSummaries.map((reason) => stringValue(reason.code || reason.queue, "")).filter(Boolean),
reasonSummaries,
};
}
function normalizedTaskQueues(task) {
const queues = Array.isArray(task.taskQueues) ? task.taskQueues.map((queue) => stringValue(queue, "")).filter(Boolean) : [];
const confirmed = stringValue(task.reviewStatus, "") === "confirmed" || task.reviewConfirmation?.confirmed === true;
const terminalQueues = new Set(["review", "confirmed", "confirmed-finalization-pending"]);
const sourceQueues = confirmed ? queues.filter((queue) => !terminalQueues.has(queue)) : queues;
if (confirmed && !sourceQueues.includes("finalized"))
sourceQueues.push("finalized");
if (confirmed && taskHasPendingLessonSignal(task) && !sourceQueues.includes("lessons"))
sourceQueues.push("lessons");
const normalized = sourceQueues.filter((queue) => queue !== "active" || taskIsCurrentlyActive(task));
if (queues.includes("active") && !taskIsCurrentlyActive(task))
normalized.push(nonActiveLifecycleQueue(task));
return normalized.length ? [...new Set(normalized.filter(Boolean))] : [taskIsCurrentlyActive(task) ? "active" : nonActiveLifecycleQueue(task)];
}
function normalizedQueueReasons(task) {
if (!Array.isArray(task.queueReasons))
return [];
return task.queueReasons
.filter((reason) => reason && typeof reason === "object")
.map((reason) => ({
code: stringValue(reason.code, ""),
queue: stringValue(reason.queue, ""),
message: stringValue(reason.message, ""),
severity: stringValue(reason.severity, ""),
}))
.filter((reason) => reason.code || reason.queue || reason.message || reason.severity);
}
function primaryReviewQueue(queues) {
const order = ["blocked", "missing-materials", "review", "lessons", "finalized", "soft-deleted-superseded", "active", "planned", "done", "unknown"];
return order.find((queue) => queues.includes(queue)) || queues[0] || "active";
}
function taskVisibleInSwimlane(task, lifecycle, reviewView) {
if (lifecycle.deletionState !== "active")
return false;
if (["done", "closed", "finalized"].includes(lifecycle.state))
return false;
if (["closed", "finalized"].includes(lifecycle.closeoutStatus))
return false;
if (reviewView.finalized && !reviewView.hasPendingLessonWork)
return false;
if (clampCompletion(task.completion) >= 100 && !["review", "blocked", "reopened", "current-evidence"].includes(lifecycle.state))
return false;
return ["active", "planned", "not_started", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(lifecycle.state)
|| reviewView.inQueue
|| ["confirmed", "blocked-open-findings"].includes(lifecycle.reviewStatus);
}
function taskIsCurrentlyActive(task) {
return stringValue(task.deletionState, "active") === "active"
&& stringValue(task.state, "unknown") === "in_progress"
&& ["active", "unknown"].includes(stringValue(task.lifecycleState, "unknown"))
&& stringValue(task.closeoutStatus, "missing") !== "closed"
&& clampCompletion(task.completion) < 100;
}
function nonActiveLifecycleQueue(task) {
if (stringValue(task.deletionState, "active") !== "active")
return "soft-deleted-superseded";
const state = stringValue(task.state, "unknown");
const lifecycleState = stringValue(task.lifecycleState, "unknown");
const closeoutStatus = stringValue(task.closeoutStatus, "missing");
const reviewStatus = stringValue(task.reviewStatus, "missing");
if (closeoutStatus === "closed" || ["closed", "finalized"].includes(lifecycleState) || reviewStatus === "confirmed")
return "finalized";
if (state === "blocked" || lifecycleState === "blocked")
return "blocked";
if (state === "review" || lifecycleState === "in_review")
return "review";
if (["planned", "not_started"].includes(state) || lifecycleState === "ready")
return "planned";
if (state === "done")
return "done";
return "unknown";
}
function taskNeedsEvidence(task) {
if (["missing", "legacy-only"].includes(stringValue(task.visualMapStatus, "")))
return true;
if (task.briefSource && task.briefSource !== "standalone")
return true;
return (task.phases || []).some((phase) => ["missing", "partial"].includes(stringValue(phase.evidenceStatus, "")));
}
function swimlaneTone(columnKey) {
if (["blocked"].includes(columnKey))
return "fail";
if (["missing-materials", "active"].includes(columnKey))
return "warn";
if (["review", "lessons", "finalized"].includes(columnKey))
return "pass";
return "muted";
}
function taskHasPendingLessonWork(task, taskQueues) {
return taskQueues.includes("lessons") || taskHasPendingLessonSignal(task);
}
function taskHasPendingLessonSignal(task) {
const candidates = Array.isArray(task.lessonCandidateRows) ? task.lessonCandidateRows : [];
return task.lessonCandidateStatus === "needs-promotion"
|| task.lessonCandidatePromotionState === "queued"
|| candidates.some((candidate) => ["ready-for-review", "needs-promotion"].includes(stringValue(candidate.status, "")));
}
function firstQueueReason(task) {
const reasons = Array.isArray(task.queueReasons) ? task.queueReasons.filter(Boolean) : [];
return reasons[0] || {};
}
function blockingRiskCount(task) {
return (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
}
function clampCompletion(value) {
const number = Number(value);
if (!Number.isFinite(number))
return 0;
return Math.max(0, Math.min(100, number));
}
function stringValue(value, fallback) {
const text = String(value || "").trim();
return text || fallback;
}
id: codex-thread-orchestration
version: 1
purpose: Create complex tasks with a Codex-only background thread orchestration method for human-visible coordinator and worker thread management.
compatibleBudgets: [complex]
localeSupport: [en-US, zh-CN]
task:
kind: codex-thread-orchestration-task
defaultTaskId: codex-thread-orchestration-task
entrypoints:
newTask:
type: template
writes: [{{paths.tasksRoot}}/**]
audit: true
templates:
taskPlanAppend: templates/task_plan.append.md
executionStrategyAppend: templates/execution_strategy.append.md
metadata:
CodexOnly:
label: Codex Only
value: yes
ThreadOrchestrationPattern:
label: Thread Orchestration Pattern
value: coordinator-background-threads
ThreadEvidenceRequired:
label: Thread Evidence Required
value: yes
resources:
references:
codexThreadOrchestrationProtocol:
path: references/codex-thread-orchestration-protocol.md
source: resources/references/codex-thread-orchestration-protocol.md
index:
id: REF-001
type: runbook
summary: Codex-only coordinator protocol for creating, titling, dispatching, reading, and archiving background worker threads.
usedBy: coordinator,worker,reviewer
context:
requiredReads: [REF-001]
evidence:
bundleDir: artifacts/preset
files:
protocol:
path: codex-thread-orchestration-protocol.txt
type: text
value: codex-thread-orchestration
audit:
manifestRequired: true
evidenceFiles: [preset-audit.json, preset-manifest.json, write-scope.json]
writeScopes:
taskDocs:
path: {{paths.tasksRoot}}/**
access: write
# Codex Thread Orchestration Protocol
## Purpose
This reference describes a Codex-only method for coordinating complex Harness tasks through background threads. It is designed for human visibility: the coordinator can keep separate workstreams in separate Codex threads while preserving the Harness task package as the durable record.
## Non-Goals
- This preset does not automate Codex App thread tools from npm Harness.
- This preset does not define repository-specific pull request, merge, release, or branch cleanup policy.
- This preset does not replace task package evidence, review, verification, or closeout records.
## Coordinator Flow
1. Read the task plan, execution strategy, and preset required references.
2. Decide whether the task has independent workstreams worth splitting into threads.
3. For each thread, define title, role, mode, scope, forbidden scope, stop conditions, and handoff format.
4. Create a background thread only after the prompt packet is complete.
5. Rename the thread with a stable title that includes the task id and workstream.
6. Send the dispatch prompt.
7. Read thread status before integrating work.
8. Record the thread id, title, summary, handoff, verification, and residual risk in the task package.
9. Archive or unpin threads after their useful output is captured.
## Suggested Thread Operations
When available in Codex, use these operations:
| Operation | Purpose |
| --- | --- |
| Create thread | Start a bounded worker, reviewer, or research workstream. |
| Send message to thread | Provide follow-up context, unblock a worker, or request a corrected handoff. |
| Read thread | Inspect recent status and output before integration. |
| Set thread title | Make ownership and workstream visible to the human operator. |
| Pin thread | Keep active coordination surfaces visible. |
| Archive thread | Remove completed workstreams after durable evidence capture. |
## Title Convention
Use stable, scan-friendly titles:
```text
<task-id> coordinator
<task-id> worker: <workstream>
<task-id> reviewer: <topic>
<task-id> research: <topic>
```
## Prompt Packet
Each thread prompt should include:
- task id and title,
- role,
- required reads,
- exact scope,
- forbidden scope,
- read-only or writable mode,
- working directory or worktree when applicable,
- verification expectations,
- stop conditions,
- handoff format.
Do not rely on hidden context from the coordinator thread. A worker should be able to understand the assignment from the prompt and referenced task files.
## Evidence Rules
Thread transcripts are not enough. The coordinator must record durable evidence in the task package:
- thread registry rows,
- dispatch prompt summaries,
- handoff summaries,
- verification results,
- accepted findings,
- skipped checks,
- residual risks,
- fallback reason if thread tools were unavailable.
## Fallback
If background thread tools are unavailable or unsuitable, record:
```text
Thread fallback: thread-tools-unavailable; using normal coordinator workflow.
```
The task can still proceed. The important part is that the task package honestly records the coordination model that was actually used.
## Codex Thread Orchestration
This section defines the coordinator method for Codex background threads. It is intentionally conceptual and runtime-specific: Harness can generate this protocol into the task package, but npm Harness does not create or manage Codex App threads by itself.
### Coordinator Responsibilities
| Responsibility | Required Handling |
| --- | --- |
| Workstream design | Split only genuinely independent work into background threads. |
| Prompt packet | Give each thread enough context to operate without hidden coordinator state. |
| Scope control | Define allowed scope, forbidden scope, mode, and stop conditions before dispatch. |
| Handoff intake | Read every worker thread before using its findings or commits. |
| Evidence capture | Copy durable outcomes into `progress.md`, `review.md`, or task-local artifacts. |
| Closeout | Archive or unpin completed worker threads only after evidence capture. |
### Thread Registry
Maintain this registry as threads are created, read, blocked, completed, or archived.
| Workstream | Thread Title | Thread ID | Mode | Scope | Status | Last Read | Handoff Evidence | Next Action |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| coordinator | pending | current thread | integration-owner | whole task | active | n/a | `progress.md` | decide worker split |
| worker-1 | pending | pending | read-only / writable | pending | not-started | never | pending | create or skip |
### Dispatch Prompt Requirements
Every worker thread prompt must include:
- task id and title,
- role: coordinator support, read-only researcher, read-only reviewer, or writable worker,
- required reads,
- assigned scope,
- forbidden scope,
- mode: read-only or writable,
- worktree or local path when writable work is allowed,
- verification commands or evidence expectations,
- stop conditions,
- exact handoff format.
### Handoff Format
Each worker thread should report:
| Field | Required Content |
| --- | --- |
| Thread ID | Codex thread id, or `unavailable`. |
| Thread title | Human-readable title used in Codex. |
| Mode | `read-only`, `writable`, or `research`. |
| Scope covered | Files, docs, task sections, or topic boundaries covered. |
| Changes made | Commit SHA or `n/a`. |
| Verification | Commands, inspections, or review checks run. |
| Findings | Confirmed findings with references. |
| Residual risk | Assumptions, skipped checks, or blockers. |
### Fallback
If Codex thread tools are unavailable, do not invent thread evidence. Record:
```text
Thread fallback: thread-tools-unavailable; using normal coordinator workflow.
```
Then continue with the smallest adequate coordination model and keep all normal task evidence requirements intact.
## Codex Thread Orchestration Preset
This task uses the `codex-thread-orchestration` preset. This is a Codex-only method preset for complex tasks that benefit from human-visible background thread coordination.
Codex threads are a coordination layer, not the durable source of truth. The task package remains responsible for scope, acceptance criteria, thread registry, handoffs, verification, review, residual risk, and closeout.
### Required Boundaries
- Use this preset only when the coordinator is running in Codex with background thread tools available.
- Do not assume other agents, npm-only Harness users, or non-Codex runtimes can create or steer Codex threads.
- Do not use background threads for vague work. Each worker thread needs explicit scope, mode, stop conditions, and handoff format.
- Do not treat a thread transcript as sufficient evidence. Summarize durable decisions and handoffs back into this task package.
- If thread tools are unavailable, record the fallback and continue with normal single-thread or subagent coordination.
### Thread Planning Checklist
| Item | Decision |
| --- | --- |
| Coordinator thread title | pending |
| Worker threads needed? | no / yes |
| Independent workstreams | pending |
| Writable worker threads | no / yes |
| Read-only reviewer threads | no / yes |
| Evidence location | `progress.md`, `review.md`, or task-local artifacts |
### Acceptance Addendum
- Every created worker thread is listed in the thread registry.
- Every worker dispatch prompt includes scope, forbidden scope, mode, stop conditions, and handoff format.
- Every worker handoff is read by the coordinator before integration.
- Final task evidence records thread IDs or an explicit `thread-tools-unavailable` fallback.
+12
-0
# Changelog
## 1.1.1
- Align lifecycle completion around the human-reviewed terminal state so
confirmed review tasks can be finalized through the current task lifecycle.
- Route task operations and task index behavior through the application and
repository layers, removing legacy facade/parser fallback paths.
- Make dashboard task semantics fail closed on missing projections and align
active-task, review-material, accessibility, typography, and hot-refresh
behavior with the projection-first model.
- Strengthen release-facing governance gates for walkthrough material checks,
transaction-backed lifecycle writes, and projection-first task operations.
## 1.1.0

@@ -4,0 +16,0 @@

+20
-85
# Contributing
Thanks for helping improve Coding Agent Harness. This repository contains the public CLI, templates, presets, Skills, examples, documentation, and the optional GUI submodule.
Thanks for helping improve Coding Agent Harness.
## Before You Start
This root file is the short GitHub entrypoint for contributors. The full
contributor guide lives in
[`docs-release/guides/contributing.md`](docs-release/guides/contributing.md).
- Use Node.js 24 or newer. CI should run on the minimum supported line.
- Install root dependencies with `npm install` from the repository root. The install lifecycle generates the local `dist/` runtime; `dist/` is not tracked in Git.
- If you change `harness-gui`, also run `npm ci` inside `harness-gui/`.
- Keep pull requests focused. Separate documentation, CLI/runtime, template, preset, and GUI work when the changes are independent.
## Start Here
## Repository Layout
- Use Node.js 24 or newer.
- Create a focused branch from the latest `main`.
- Keep pull requests scoped to one change family when possible.
- Run the checks that match your change and record the results in the PR.
| Path | Purpose |
| --- | --- |
| `scripts/` | Public CLI and implementation modules. |
| `dist/` | Generated runtime output. It is published to npm but ignored in Git. |
| `tests/` | Root package tests and dashboard smoke tests. |
| `templates/`, `templates-zh-CN/` | Harness templates installed into target projects. |
| `presets/` | Bundled Harness preset packages. |
| `skills/`, `SKILL.md` | Agent Skill entrypoints and nested Skills. |
| `docs-release/` | Public documentation for users and maintainers. |
| `examples/` | Minimal target projects and fixtures used by tests. |
| `harness-gui/` | Optional GUI submodule with its own package and checks. |
## Common Checks
Do not commit local generated dashboards, temporary output directories, credentials, editor files, or machine-specific environment files.
For docs-only changes, run:
## Branches And Commits
- Create a branch from the latest `main`.
- Use a descriptive branch name such as `codex/fix-task-state-docs` or `feat/preset-validation`.
- Keep commit messages short and concrete.
- Do not include unrelated formatting churn or generated artifacts in the same PR.
## What To Run
Run the checks that match your change. For a small docs-only PR, start with the
docs row. For larger PRs or when you are unsure, run the full root suite.
| Change type | Minimum local checks |
| --- | --- |
| Docs only | `git diff --check` |
| CLI/runtime | `npm run typecheck`, `npm run typecheck:guards`, `npm test`, `npm run check`, `git diff --check` |
| Templates or examples | `npm test`, `npm run build:runtime`, `node dist/harness.mjs check --profile target-project examples/minimal-project`, `git diff --check` |
| Dashboard | `npm test`, `npm run smoke:dashboard`, `git diff --check` |
| Package surface | `npm test`, `npm run pack:dry-run`, `git diff --check` |
| GUI submodule | `cd harness-gui && npm ci && npm run typecheck && npm test && npm run build` |
Full root suite:
```bash
npm install
npm run build:runtime
npm run typecheck
npm run typecheck:guards
npm test
npm run smoke:dashboard
npm run check
node dist/harness.mjs check --profile target-project examples/minimal-project
npm run pack:dry-run
git diff --check
```
GUI submodule setup and checks:
For code, templates, presets, dashboard, package-surface, or GUI changes, use
the full guide:
```bash
cd harness-gui
npm ci
npm run typecheck
npm test
npm run build
```
- [Contributor Guide](docs-release/guides/contributing.md)
- [中文贡献者指南](docs-release/guides/contributing.zh-CN.md)
If a check is not relevant or cannot run in your environment, explain that in the PR template.
## Change-Specific Guidance
- CLI/runtime changes usually need root tests in `tests/` and source-package validation.
- Template changes should prove that a target project still passes `check --profile target-project`.
- Dashboard changes should run `npm run smoke:dashboard`.
- Preset changes should include preset validation and at least one task creation path when behavior changes.
- Documentation-only changes should still run `git diff --check` and any targeted link or spelling checks you use.
- GUI changes must be made in the `harness-gui` submodule and validated with the GUI commands above.
## Pull Requests
Every PR should include:
Use the repository PR template and include what changed, why it matters,
verification evidence, version impact, checks not run, and known residual risk.
- What changed and why.
- The user or maintainer impact.
- Whether the package version changes. Use "no version change" for docs, CI-only, and internal maintenance unless release behavior changes.
- Verification commands and outcomes.
- Any checks not run, with a reason.
- Known residual risk.
Use draft PRs for work that still needs design review, incomplete checks, or follow-up fixes. Mark the PR ready only after the relevant checks pass and review feedback is addressed.
## CI Expectations
GitHub Actions validates the root TypeScript integrity gate (`npm run typecheck` and `npm run typecheck:guards`), root package tests, source/package boundary, minimal target project, dashboard smoke path, npm package dry run, and GUI submodule typecheck/test/build path. A local run should match the CI commands closely enough that failures are reproducible.
Repository owners may configure branch protection and required checks separately in GitHub. Contributors do not need to manage those settings.
Do not commit local generated dashboards, temporary output directories,
credentials, editor files, machine-specific environment files, or ignored
local-only Harness state.

@@ -76,2 +76,3 @@ #!/usr/bin/env node

assignLayers(nodesByPath, cycleNodeSet);
const architectureBoundaryViolations = findArchitectureBoundaryViolations(nodesByPath);
const nodes = [...nodesByPath.values()].sort((left, right) => left.path.localeCompare(right.path));

@@ -94,2 +95,3 @@ const localEdgeCount = nodes.reduce((count, node) => count + node.imports.filter((imported) => imported.target).length, 0);

typesValueImports: typesValueImports.length,
architectureBoundaryViolations: architectureBoundaryViolations.length,
binReachableFiles: nodes.filter((node) => node.reachableFromBin).length,

@@ -103,2 +105,3 @@ harnessCoreBarrelTargets: barrelTargets.length,

typesValueImports,
architectureBoundaryViolations,
};

@@ -125,2 +128,5 @@ }

}
for (const edge of graph.architectureBoundaryViolations) {
violations.push(edge);
}
const barrels = graph.nodes.filter((node) => node.path === "scripts/lib/harness-core.mts" || node.path === "scripts/lib/harness-core.mjs");

@@ -379,2 +385,97 @@ for (const barrel of barrels) {

}
function findArchitectureBoundaryViolations(nodesByPath) {
const violations = [];
for (const node of nodesByPath.values()) {
for (const edge of node.imports) {
if (!edge.target)
continue;
const code = architectureBoundaryCode(node.path, edge.target);
if (!code)
continue;
violations.push({
code,
file: node.path,
target: edge.target,
specifier: edge.specifier,
message: architectureBoundaryMessage(code, node.path, edge.target),
});
}
}
return violations.sort((left, right) => `${left.file}:${left.code}:${left.target}`.localeCompare(`${right.file}:${right.code}:${right.target}`));
}
function architectureBoundaryCode(file, target) {
if (file.startsWith("scripts/infrastructure/kernel/") && !target.startsWith("scripts/infrastructure/kernel/")) {
return "kernel-imports-outer-layer";
}
if (file.startsWith("scripts/domain/") && (target.startsWith("scripts/adapters/") || target.startsWith("scripts/application/"))) {
return "domain-imports-outer-layer";
}
if (file.startsWith("scripts/domain/") && target.startsWith("scripts/infrastructure/") && !target.startsWith("scripts/infrastructure/kernel/")) {
return "domain-imports-infrastructure";
}
if (file.startsWith("scripts/application/") && target.startsWith("scripts/adapters/")) {
return "application-imports-adapter";
}
if ((file.startsWith("scripts/commands/") || file === "scripts/lib/dashboard-workbench.mts") && target === "scripts/lib/task-operations.mts") {
return "runtime-imports-task-operations-facade";
}
if (file.startsWith("scripts/adapters/") && isTaskSourceOfTruthInternal(target)) {
return "adapter-imports-task-internal";
}
if (file.startsWith("scripts/commands/") && isTaskSourceOfTruthInternal(target)) {
return "command-imports-task-internal";
}
if (file === "scripts/lib/dashboard-workbench.mts" && isTaskSourceOfTruthInternal(target)) {
return "dashboard-workbench-imports-task-internal";
}
if (file === "scripts/lib/dashboard-data.mts" && isTaskSourceOfTruthInternal(target)) {
return "dashboard-data-imports-task-internal";
}
if (file === "scripts/lib/governance-index-generator.mts" && target === "scripts/lib/task-scanner.mts") {
return "generated-governance-imports-task-scanner";
}
if (isPresetRuntimePath(file) && target === "scripts/lib/governance-sync.mts") {
return "preset-runtime-imports-governance-sync";
}
return "";
}
function architectureBoundaryMessage(code, file, target) {
if (code === "kernel-imports-outer-layer")
return `${file} is infrastructure/kernel and must not import outer layer module ${target}`;
if (code === "domain-imports-outer-layer")
return `${file} is domain code and must not import outer layer module ${target}`;
if (code === "domain-imports-infrastructure")
return `${file} is domain code and may only import infrastructure/kernel, not ${target}`;
if (code === "application-imports-adapter")
return `${file} is application code and must not import adapter module ${target}`;
if (code === "runtime-imports-task-operations-facade")
return `${file} must import TaskOperations from scripts/application/task, not the scripts/lib compatibility facade`;
if (code === "adapter-imports-task-internal")
return `${file} adapter must go through application/repository boundaries, not task internal ${target}`;
if (code === "command-imports-task-internal")
return `${file} command adapter must go through application/repository boundaries, not task internal ${target}`;
if (code === "dashboard-workbench-imports-task-internal")
return `${file} must go through application workbench boundaries, not task internal ${target}`;
if (code === "dashboard-data-imports-task-internal")
return `${file} must consume Task projection/repository outputs, not task internal ${target}`;
if (code === "generated-governance-imports-task-scanner")
return `${file} must consume TaskRepository/projection records, not scanner internals ${target}`;
if (code === "preset-runtime-imports-governance-sync")
return `${file} must use HarnessTransaction/OperationPlan boundaries, not governance-sync directly`;
return `${file} violates architecture boundary by importing ${target}`;
}
function isTaskSourceOfTruthInternal(target) {
return [
"scripts/lib/task-scanner.mts",
"scripts/lib/task-lifecycle.mts",
"scripts/lib/governance-sync.mts",
].includes(target);
}
function isPresetRuntimePath(file) {
return [
"scripts/lib/preset-runner.mts",
"scripts/lib/preset-engine.mts",
"scripts/lib/preset-registry.mts",
].includes(file) || file.startsWith("scripts/domain/preset/");
}
function findCycles(nodesByPath) {

@@ -511,2 +612,3 @@ const indexByPath = new Map();

`typesValueImports=${graph.summary.typesValueImports}`,
`architectureBoundaries=${graph.summary.architectureBoundaryViolations}`,
].join(", "));

@@ -513,0 +615,0 @@ }

import { normalizeTarget, prepareModuleRegistration, prepareModuleScaffold, prepareModuleUnregister, readHarnessModules, } from "../lib/harness-core.mjs";
import { takeRepeatedOptionsFromArgs } from "../lib/command-registry.mjs";
import { beginGovernanceSync, commitGovernanceSync, governanceRelativePaths, releaseGovernanceSync } from "../lib/governance-sync.mjs";
import { runModuleGovernanceTransaction } from "../application/module/module-governance.mjs";
export function runModuleCommand({ args, takeFlag, takeOption, targetArg }) {

@@ -53,12 +53,10 @@ const subcommand = args.shift() || "list";

const planned = prepareModuleRegistration(target, moduleKey, input, { dryRun: true });
const context = beginGovernanceSync(target, {
operation: `module register ${moduleKey}`,
dryRun,
allowDirtyWorktree: true,
allowedRelativePaths: governanceRelativePaths(planned.changes),
});
try {
const result = prepareModuleRegistration(target, moduleKey, input, { dryRun });
const commit = commitGovernanceSync(context, governanceRelativePaths(result.changes), { message: `chore(harness): register module ${result.moduleKey}` });
console.log(JSON.stringify({ ...result, governance: { commit } }, null, 2));
const result = runModuleGovernanceTransaction(target, planned.changes, {
operation: `module register ${moduleKey}`,
dryRun,
message: `chore(harness): register module ${moduleKey}`,
run: () => prepareModuleRegistration(target, moduleKey, input, { dryRun }),
});
console.log(JSON.stringify(result, null, 2));
}

@@ -69,5 +67,2 @@ catch (error) {

}
finally {
releaseGovernanceSync(context);
}
return;

@@ -84,12 +79,10 @@ }

const planned = prepareModuleUnregister(target, moduleKey, { dryRun: true });
const context = beginGovernanceSync(target, {
operation: `module unregister ${moduleKey}`,
dryRun,
allowDirtyWorktree: true,
allowedRelativePaths: governanceRelativePaths(planned.changes),
});
try {
const result = prepareModuleUnregister(target, moduleKey, { dryRun });
const commit = commitGovernanceSync(context, governanceRelativePaths(result.changes), { message: `chore(harness): unregister module ${result.moduleKey}` });
console.log(JSON.stringify({ ...result, governance: { commit } }, null, 2));
const result = runModuleGovernanceTransaction(target, planned.changes, {
operation: `module unregister ${moduleKey}`,
dryRun,
message: `chore(harness): unregister module ${moduleKey}`,
run: () => prepareModuleUnregister(target, moduleKey, { dryRun }),
});
console.log(JSON.stringify(result, null, 2));
}

@@ -100,5 +93,2 @@ catch (error) {

}
finally {
releaseGovernanceSync(context);
}
return;

@@ -119,14 +109,15 @@ }

const plannedChanges = keys.flatMap((key) => prepareModuleScaffold(target, key, { dryRun: true, locale }).changes);
const context = beginGovernanceSync(target, {
operation: all ? "module scaffold --all" : `module scaffold ${moduleKey}`,
dryRun,
allowDirtyWorktree: true,
allowedRelativePaths: governanceRelativePaths(plannedChanges),
allowDirtyWriteScope: true,
});
try {
const results = keys.map((key) => prepareModuleScaffold(target, key, { dryRun, locale }));
const changes = results.flatMap((result) => result.changes);
const commit = commitGovernanceSync(context, governanceRelativePaths(changes), { message: all ? "chore(harness): scaffold registered modules" : `chore(harness): scaffold module ${moduleKey}` });
console.log(JSON.stringify({ modules: results, changes, governance: { commit } }, null, 2));
const result = runModuleGovernanceTransaction(target, plannedChanges, {
operation: all ? "module scaffold --all" : `module scaffold ${moduleKey}`,
dryRun,
message: all ? "chore(harness): scaffold registered modules" : `chore(harness): scaffold module ${moduleKey}`,
allowDirtyWriteScope: true,
run: () => {
const modules = keys.map((key) => prepareModuleScaffold(target, key, { dryRun, locale }));
const changes = modules.flatMap((item) => item.changes);
return { modules, changes };
},
});
console.log(JSON.stringify(result, null, 2));
}

@@ -137,5 +128,2 @@ catch (error) {

}
finally {
releaseGovernanceSync(context);
}
return;

@@ -142,0 +130,0 @@ }

@@ -1,3 +0,4 @@

import { createTask, readPresetPackage, buildTaskIndex, createLessonSedimentationTask, archiveTask, deleteTask, listLifecycleTasks, promoteLessonCandidate, reopenTask, supersedeTask, updateModuleStep, updateTaskPhase, updateTaskLifecycle, } from "../lib/harness-core.mjs";
import { readPresetPackage, buildTaskIndex, listLifecycleTasks, promoteLessonCandidate, updateModuleStep, updateTaskPhase, } from "../lib/harness-core.mjs";
import { takeRepeatedOptionsFromArgs } from "../lib/command-registry.mjs";
import { createTaskOperations, unwrapTaskOperation } from "../application/task/task-operations.mjs";
export function runTaskCommand(command, { args, takeFlag, takeOption, targetArg }) {

@@ -29,3 +30,3 @@ if (command === "new-task") {

const createOptions = { title, locale, dryRun, moduleKey, budget, longRunning, preset, fromSession, presetArgs: parsed.presetArgs, automaticTaskId: parsed.automaticTaskId, registerModule, moduleRegistration };
console.log(JSON.stringify(invokeCreateTask(parsed.target, parsed.taskId, createOptions), null, 2));
console.log(JSON.stringify(unwrapTaskOperation(createTaskOperations(parsed.target).create({ taskId: parsed.taskId, ...createOptions })), null, 2));
}

@@ -74,3 +75,11 @@ catch (error) {

try {
console.log(JSON.stringify(updateTaskLifecycle(targetArg(), taskId, { ...lifecycle, message, evidence }), null, 2));
const operations = createTaskOperations(targetArg());
const result = command === "task-start"
? operations.start({ taskId, message, evidence })
: command === "task-review"
? operations.review({ taskId, message, evidence })
: command === "task-complete"
? operations.complete({ taskId, message, evidence })
: operations.updateLifecycle({ taskId, ...lifecycle, message, evidence });
console.log(JSON.stringify(unwrapTaskOperation(result), null, 2));
}

@@ -111,3 +120,3 @@ catch (error) {

try {
console.log(JSON.stringify(createLessonSedimentationTask(targetArg(), taskId, candidateId, { dryRun, title }), null, 2));
console.log(JSON.stringify(unwrapTaskOperation(createTaskOperations(targetArg()).lessonSediment({ taskId, candidateId, dryRun, title })), null, 2));
}

@@ -163,3 +172,3 @@ catch (error) {

try {
console.log(JSON.stringify(supersedeTask(targetArg(), taskId, { by, reason, deletedBy, confirm, allowOpenFindings }), null, 2));
console.log(JSON.stringify(unwrapTaskOperation(createTaskOperations(targetArg()).supersede({ taskId, by, reason, deletedBy, confirm, allowOpenFindings })), null, 2));
}

@@ -190,7 +199,7 @@ catch (error) {

const result = command === "task-delete"
? deleteTask(targetArg(), taskId, { hard, reason, deletedBy, confirm, allowOpenFindings })
? createTaskOperations(targetArg()).delete({ taskId, hard, reason, deletedBy, confirm, allowOpenFindings })
: command === "task-archive"
? archiveTask(targetArg(), taskId, { reason, archivedBy, archiveFields })
: reopenTask(targetArg(), taskId, { reason });
console.log(JSON.stringify(result, null, 2));
? createTaskOperations(targetArg()).archive({ taskId, reason, archivedBy, archiveFields })
: createTaskOperations(targetArg()).reopen({ taskId, reason });
console.log(JSON.stringify(unwrapTaskOperation(result), null, 2));
}

@@ -235,5 +244,2 @@ catch (error) {

}
function invokeCreateTask(target, taskId, options) {
return Reflect.apply(createTask, undefined, [target, taskId, options]);
}
function readPresetPackageForNewTask(preset, values) {

@@ -240,0 +246,0 @@ const candidates = presetDiscoveryTargetCandidates(values);

@@ -5,3 +5,11 @@ import fs from "node:fs";

import { fileURLToPath } from "node:url";
import { localDate, nowTimestamp, todayDate, datePrefix } from "../infrastructure/kernel/date-utils.mjs";
import { readFileSafe, readJsonSafe, walkFiles } from "../infrastructure/kernel/file-system.mjs";
import { prefixedPath, toPosix } from "../infrastructure/kernel/path-utils.mjs";
import { sanitizeDeep, sanitizeText, slug, titleFromMarkdown } from "../infrastructure/kernel/text-utils.mjs";
import { resolveHarnessPaths } from "./harness-paths.mjs";
export { localDate, nowTimestamp, todayDate, datePrefix } from "../infrastructure/kernel/date-utils.mjs";
export { readFileSafe, readJsonSafe, walkFiles } from "../infrastructure/kernel/file-system.mjs";
export { prefixedPath, toPosix } from "../infrastructure/kernel/path-utils.mjs";
export { sanitizeDeep, sanitizeText, slug, titleFromMarkdown } from "../infrastructure/kernel/text-utils.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));

@@ -74,5 +82,2 @@ export const repoRoot = path.resolve(__dirname, "../..");

}
export function toPosix(value) {
return value.split(path.sep).join("/");
}
export function exists(target, relativePath) {

@@ -84,20 +89,2 @@ return fs.existsSync(path.join(target.projectRoot, relativePath));

}
export function readFileSafe(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
}
catch {
return "";
}
}
export function readJsonSafe(filePath, fallback = null, { onError } = {}) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
catch (error) {
if (typeof onError === "function")
onError(error);
return fallback;
}
}
export function readBundledTemplate(source) {

@@ -176,26 +163,2 @@ const sourcePath = path.join(repoRoot, source);

}
export function walkFiles(root, options = {}) {
const results = [];
if (!fs.existsSync(root))
return results;
const dirFilter = typeof options.dirFilter === "function" ? options.dirFilter : () => true;
function walk(dir) {
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if ([".git", "node_modules", "tmp"].includes(entry))
continue;
if (!dirFilter(entry, full))
continue;
walk(full);
}
else {
results.push(full);
}
}
}
walk(root);
return results;
}
export function isArchivedHarnessPath(filePath) {

@@ -222,37 +185,2 @@ const normalized = `/${toPosix(filePath)}/`;

}
export function slug(value) {
return String(value || "item")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "item";
}
export function prefixedPath(target, filePath) {
return `TARGET:${toPosix(path.relative(target.projectRoot, filePath))}`;
}
export function sanitizeText(value) {
return String(value ?? "")
.replace(/file:\/\/\/[^\s)"'`<>\]]+/g, "LOCAL_FILE_URL_REDACTED")
.replaceAll("file://", "LOCAL_FILE_URL_REDACTED")
.replace(/\/Users\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/Volumes\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/(?:private\/)?tmp\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/var\/folders\/[^\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/\/home\/[^/\s)"'`<>\]]+(?:\/[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED")
.replace(/[A-Za-z]:\\[^\s)"'`<>\]]+(?:\\[^\s)"'`<>\]]*)*/g, "LOCAL_PATH_REDACTED");
}
export function sanitizeDeep(value) {
if (typeof value === "string")
return sanitizeText(value);
if (Array.isArray(value))
return value.map(sanitizeDeep);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeDeep(entry)]));
}
return value;
}
export function titleFromMarkdown(content, fallback) {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : fallback;
}
export function localizedTemplateSource(source, locale) {

@@ -262,16 +190,2 @@ const localeSource = normalizeLocale(locale) === "zh-CN" ? source.replace(/^templates\//, "templates-zh-CN/") : source;

}
export function todayDate() {
return localDate();
}
export function localDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export const datePrefix = /^\d{4}-\d{2}-\d{2}-/;
export function nowTimestamp() {
return new Date().toISOString().replace("T", " ").slice(0, 16);
}
export function normalizeTaskId(value) {

@@ -278,0 +192,0 @@ return slug(value || "task");

@@ -1,2 +0,2 @@

// Dashboard bundle aggregation stays behavior-first until dashboard domain types are modeled.
// Dashboard bundle aggregation is an explicit transport contract for the generated UI.
import fs from "node:fs";

@@ -16,2 +16,3 @@ import path from "node:path";

import { summarizeGitState } from "./git-status-summary.mjs";
export const dashboardBundleSchemaVersion = "dashboard-bundle/v1";
export function collectMarkdownDocuments(target, options = {}) {

@@ -381,8 +382,8 @@ const docs = collectDashboardDocumentPaths(target, options);

const unclassifiedTasks = tasks.filter((task) => !isArchivedDashboardTask(task) && dashboardTaskModuleKey(task) === "legacy-unclassified");
const moduleList = [...modules.values()].sort((left, right) => {
const leftStatus = String(left.status || "");
const rightStatus = String(right.status || "");
if (leftStatus === "in-progress" && rightStatus !== "in-progress")
const moduleList = [...modules.values()].map(withDashboardModuleView).sort((left, right) => {
const leftStatus = String(left.dashboardModuleView?.statusKey || left.status || "");
const rightStatus = String(right.dashboardModuleView?.statusKey || right.status || "");
if (leftStatus === "in_progress" && rightStatus !== "in_progress")
return -1;
if (rightStatus === "in-progress" && leftStatus !== "in-progress")
if (rightStatus === "in_progress" && leftStatus !== "in_progress")
return 1;

@@ -403,2 +404,65 @@ return left.key.localeCompare(right.key);

}
function withDashboardModuleView(module) {
const sourceKind = normalizedModuleSourceKind(module.source);
const statusKey = normalizedModuleStatusKey(module.status || (Number(module.counts.active || 0) > 0 ? "in_progress" : "planned"));
const view = {
key: module.key,
title: module.title || module.key,
sourceKind,
sourceLabelKey: moduleSourceLabelKey(sourceKind),
statusKey,
statusLabelKey: moduleStatusLabelKey(statusKey),
statusTone: moduleStatusTone(statusKey),
counts: module.counts,
};
return {
...module,
dashboardModuleView: view,
moduleProjection: view,
};
}
function normalizedModuleSourceKind(value) {
const source = String(value || "").trim();
if (source === "registry" || source === "inferred" || source === "structure" || source === "graph")
return source;
return source || "unknown";
}
function moduleSourceLabelKey(sourceKind) {
const map = {
registry: "moduleSourceRegistry",
inferred: "moduleSourceInferred",
structure: "moduleSourceStructure",
graph: "moduleSourceGraph",
unknown: "moduleSourceUnknown",
};
return map[sourceKind] || "moduleSourceUnknown";
}
function normalizedModuleStatusKey(value) {
const status = String(value || "").trim().replaceAll("-", "_");
if (status === "in_progress" || status === "planned" || status === "active" || status === "blocked" || status === "done" || status === "unknown")
return status;
if (status === "registry" || status === "structure" || status === "inferred")
return "planned";
return status || "unknown";
}
function moduleStatusLabelKey(statusKey) {
const map = {
active: "active",
in_progress: "state_in_progress",
planned: "state_planned",
blocked: "state_blocked",
done: "state_done",
unknown: "state_unknown",
};
return map[statusKey] || `state_${statusKey}`;
}
function moduleStatusTone(statusKey) {
if (statusKey === "blocked")
return "fail";
if (statusKey === "planned" || statusKey === "unknown")
return "warn";
if (statusKey === "active" || statusKey === "in_progress" || statusKey === "done")
return "pass";
return "";
}
function emptyModuleCounts() {

@@ -420,6 +484,6 @@ return {

function accumulateModuleTask(module, task) {
const state = String(task.state || "unknown");
const state = dashboardTaskStateValue(task);
module.counts.total += 1;
module.counts[state] = (module.counts[state] || 0) + 1;
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(state))
if (["active", "missing-materials", "blocked", "review", "lessons"].includes(state))
module.counts.active += 1;

@@ -440,2 +504,6 @@ if (dashboardTaskHasRisk(task))

lifecycleState: task.lifecycleState,
taskLifecycleProjection: task.taskLifecycleProjection,
dashboardTaskView: task.dashboardTaskView,
reviewWorkbenchQueueView: task.reviewWorkbenchQueueView,
semanticProjection: task.semanticProjection,
taskQueues: task.taskQueues,

@@ -457,4 +525,9 @@ queueReasons: task.queueReasons,

function dashboardTaskHasRisk(task) {
if (task.state === "blocked")
const reviewView = dashboardTaskReviewWorkbenchQueueView(task);
if (reviewView.blocked === true || reviewView.needsMaterials === true)
return true;
if (Array.isArray(reviewView.reasonCodes) && reviewView.reasonCodes.length > 0)
return true;
if (dashboardTaskStateValue(task) === "blocked")
return true;
if (String(task.reviewStatus || "").includes("blocked"))

@@ -470,2 +543,35 @@ return true;

}
function dashboardTaskStateValue(task) {
const reviewView = dashboardTaskReviewWorkbenchQueueView(task);
if (reviewView.primaryQueue)
return String(reviewView.primaryQueue);
const queues = Array.isArray(reviewView.queues) ? reviewView.queues : [];
if (queues.length)
return String(queues[0]);
return "active";
}
function dashboardTaskLifecycleProjection(task) {
const direct = task.taskLifecycleProjection;
if (direct && typeof direct === "object")
return direct;
const projection = task.semanticProjection;
if (projection && typeof projection === "object") {
const nested = projection.taskLifecycleProjection;
if (nested && typeof nested === "object")
return nested;
}
return {};
}
function dashboardTaskReviewWorkbenchQueueView(task) {
const direct = task.reviewWorkbenchQueueView;
if (direct && typeof direct === "object")
return direct;
const projection = task.semanticProjection;
if (projection && typeof projection === "object") {
const nested = projection.reviewWorkbenchQueueView;
if (nested && typeof nested === "object")
return nested;
}
return {};
}
function dashboardTaskMissingDocs(task) {

@@ -621,2 +727,27 @@ return task.briefSource !== "standalone" || String(task.visualMapStatus || "") === "missing";

});
const existingBriefPaths = new Set(warnings.filter((warning) => warning.type === "missing-brief").map((warning) => warning.affected));
const briefWarnings = (Array.isArray(status.tasks) ? status.tasks : [])
.filter((task) => task.briefSource !== "standalone")
.filter((task) => !existingBriefPaths.has(String(task.path || "")))
.map((task, index) => {
const state = dashboardTaskStateValue(task);
return {
id: `VB-${String(index + 1).padStart(3, "0")}`,
category: "Visibility Layer",
type: "missing-brief",
scope: "task",
priority: ["active", "missing-materials", "blocked", "review", "lessons"].includes(state) ? "P2" : "P3",
phase: "active-task-contracts",
fixability: "guided",
status: "open",
confidence: state === "unknown" ? "medium" : "high",
severity: "advice",
title: "Visibility brief missing",
affected: String(task.path || ""),
affectedPaths: [String(task.path || "")].filter(Boolean),
requiredAction: "Add a human-readable brief before this task is treated as migrated.",
detail: `${String(task.id || "")} ${String(task.title || "")}`.trim(),
};
});
const warningQueue = [...warnings, ...briefWarnings];
return {

@@ -627,6 +758,9 @@ mode: status.mode,

blockers: status.checkState.failures,
advice: warnings.length,
...summarizeWarnings(warnings),
advice: warningQueue.length,
...summarizeWarnings(warningQueue),
},
warnings,
warnings: warningQueue,
warningProjection: {
queue: warningQueue,
},
manualSteps: {

@@ -720,2 +854,3 @@ zh: [

const documents = { documents: collectMarkdownDocuments(target, { tasks: status.tasks }) };
attachDocumentProjection(status, documents.documents);
const tables = collectTables(documents.documents);

@@ -726,4 +861,38 @@ const graph = collectGraph(status, tables, target);

const presetCatalog = collectPresetCatalog(targetInput, target, options);
return sanitizeDeep({ status, tables, documents, graph, modules: modules.modules, moduleSummary: modules.summary, adoption, presetCatalog });
return sanitizeDeep({
schemaVersion: dashboardBundleSchemaVersion,
status,
tables,
documents,
graph,
modules: modules.modules,
moduleSummary: modules.summary,
adoption,
presetCatalog,
});
}
function attachDocumentProjection(status, documents) {
const tasks = Array.isArray(status.tasks) ? status.tasks : [];
for (const task of tasks) {
const taskPath = String(task.path || "");
if (!taskPath)
continue;
const byKey = {};
for (const document of documents) {
if (document.path !== taskPath && !document.path.startsWith(`${taskPath}/`))
continue;
const key = documentKeyForTaskPath(taskPath, document.path);
if (key)
byKey[key] = document;
}
task.documentsByKey = byKey;
task.documentProjection = { byKey };
}
}
function documentKeyForTaskPath(taskPath, documentPath) {
const relative = documentPath.slice(taskPath.length).replace(/^\/+/, "");
if (!relative)
return "";
return relative.split("/").pop() || "";
}
function runDashboardCompatibilityCheck(target) {

@@ -730,0 +899,0 @@ const checkTarget = target.docsOnly ? target.projectRoot : target.input;

@@ -8,8 +8,9 @@ // Dashboard workbench HTTP handlers stay behavior-first until workbench request/response types are modeled.

import { URL } from "node:url";
import { confirmTaskReview, finalizeDeferredTaskReviewConfirmation, updateTaskLifecycle } from "./task-lifecycle.mjs";
import { createAggregateLessonSedimentationTask, createLessonSedimentationTask } from "./task-lesson-sedimentation.mjs";
import { normalizeTarget } from "./core-shared.mjs";
import { beginGovernanceSync, commitGovernanceSync, releaseGovernanceSync } from "./governance-sync.mjs";
import { commitWorkbenchBatch } from "../application/workbench/review-confirmation.mjs";
import { createAggregateLessonSedimentationTask } from "./task-lesson-sedimentation.mjs";
import { normalizeTarget, toPosix } from "./core-shared.mjs";
import { dashboardWatchRoots } from "./harness-paths.mjs";
import { createScannerTaskRepository } from "./task-repository.mjs";
import { createTaskOperations, taskOperationFailurePayload } from "../application/task/task-operations.mjs";
import { confirmTaskReview as confirmTaskReviewWithContext, finalizeDeferredTaskReviewConfirmation as finalizeDeferredTaskReviewConfirmationWithContext, } from "./task-lifecycle/review-confirm.mjs";
import { writeDashboardFolder } from "./dashboard-data.mjs";

@@ -23,2 +24,3 @@ import { checkPresetPackage, installPresetPackage, listPresetPackages, seedBundledPresets, uninstallPresetPackage, } from "./preset-registry.mjs";

const taskRepository = createScannerTaskRepository(target);
const taskOperations = createTaskOperations(target.projectRoot, { repository: taskRepository });
const outputDir = path.resolve(outDir);

@@ -54,16 +56,4 @@ const csrfToken = crypto.randomBytes(24).toString("hex");

const taskId = String(body.taskId || "");
const task = findWorkbenchTask(taskRepository, taskId);
if (!task) {
writeJson(response, 404, { error: "Task not found" });
return;
}
if (!isTaskInReviewQueue(task)) {
writeJson(response, 409, reviewQueueRejectionPayload(task));
return;
}
if (task.reviewStatus === "confirmed") {
writeJson(response, 409, { error: "Review is already confirmed." });
return;
}
const result = confirmTaskReview(target.projectRoot, taskId, {
const result = taskOperations.confirmReview({
taskId,
reviewer: body.reviewer || "Human Reviewer",

@@ -74,4 +64,8 @@ message: body.message || "confirmed from dashboard workbench",

});
if (!result.success) {
writeJson(response, result.status, taskOperationFailurePayload(result));
return;
}
regenerate();
writeJson(response, 200, result);
writeJson(response, 200, result.data);
return;

@@ -83,23 +77,13 @@ }

const taskId = String(body.taskId || "");
const task = findWorkbenchTask(taskRepository, taskId);
if (!task) {
writeJson(response, 404, { error: "Task not found" });
return;
}
if (taskHasPendingLessonWork(task)) {
writeJson(response, 409, closeoutRejectionPayload(task, "Lesson candidate promotion or sedimentation work must be resolved before closeout."));
return;
}
if (!taskReadyForCloseout(task)) {
writeJson(response, 409, closeoutRejectionPayload(task));
return;
}
const result = updateTaskLifecycle(target.projectRoot, taskId, {
event: "task-complete",
state: "done",
const result = taskOperations.complete({
taskId,
message: body.message || "closed from dashboard workbench",
evidence: body.evidence || "",
});
if (!result.success) {
writeJson(response, result.status, taskOperationFailurePayload(result));
return;
}
regenerate();
writeJson(response, 200, result);
writeJson(response, 200, result.data);
return;

@@ -117,26 +101,23 @@ }

const results = [];
const taskCache = buildWorkbenchTaskCache(taskRepository, target.projectRoot);
for (const taskId of taskIds) {
const task = findWorkbenchTask(taskRepository, taskId);
if (!task) {
results.push({ taskId, ok: false, status: 404, error: "Task not found" });
const task = findCachedWorkbenchTask(taskCache, taskId);
const block = task ? bulkReviewConfirmationBlock(task) : { status: 404, reason: `Task not found: ${taskId}`, payload: { taskId } };
if (!task || block) {
results.push({ taskId, ok: false, status: block?.status || 404, error: block?.reason || `Task not found: ${taskId}`, payload: block?.payload || { taskId } });
continue;
}
if (!isTaskInReviewQueue(task)) {
const payload = reviewQueueRejectionPayload(task);
results.push({ taskId, ok: false, status: 409, ...payload });
continue;
}
if (task.reviewStatus === "confirmed") {
results.push({ taskId, ok: false, status: 409, error: "Review is already confirmed." });
continue;
}
try {
const result = confirmTaskReview(target.projectRoot, taskId, {
const payload = confirmTaskReviewWithContext({
target,
taskDir: taskDirectoryForCachedTask(target.projectRoot, task),
findTaskByDirectory: (_target, taskDir) => findCachedTaskByDirectory(taskCache, taskDir),
}, {
reviewer: body.reviewer || "Human Reviewer",
message: body.message || "bulk confirmed from dashboard workbench",
evidence: body.evidence || "",
confirmText: task.shortId || task.id,
confirmText: task.shortId || task.id || taskId,
deferCommit: true,
});
results.push({ taskId, ok: true, status: 200, audit: result.audit, task: { id: result.task?.id || taskId } });
results.push({ taskId, ok: true, status: 200, audit: payload.audit, task: { id: payload.task?.id || taskId } });
}

@@ -153,3 +134,10 @@ catch (error) {

for (const result of results.filter((item) => item.ok)) {
finalizeDeferredTaskReviewConfirmation(target.projectRoot, result.taskId, { commitSha: confirmCommit.commitSha });
const task = findCachedWorkbenchTask(taskCache, result.taskId);
if (!task)
continue;
finalizeDeferredTaskReviewConfirmationWithContext({
target,
taskDir: taskDirectoryForCachedTask(target.projectRoot, task),
findTaskByDirectory: (_target, taskDir) => findCachedTaskByDirectory(taskCache, taskDir),
}, { commitSha: confirmCommit.commitSha });
}

@@ -175,7 +163,2 @@ const auditCommit = commitWorkbenchBatch(target, allowedPaths, { operation: "review-complete-bulk-audit", message: "chore: record selected review confirmation audit" });

const candidateId = String(body.candidateId || "");
const task = findWorkbenchTask(taskRepository, taskId);
if (!task) {
writeJson(response, 404, { error: "Task not found" });
return;
}
if (!candidateId) {

@@ -185,7 +168,13 @@ writeJson(response, 400, { error: "Missing lesson candidate id" });

}
const result = createLessonSedimentationTask(target.projectRoot, taskId, candidateId, {
const result = taskOperations.lessonSediment({
taskId,
candidateId,
title: body.title || "",
});
if (!result.success) {
writeJson(response, result.status, taskOperationFailurePayload(result));
return;
}
regenerate();
writeJson(response, 200, result);
writeJson(response, 200, result.data);
return;

@@ -335,17 +324,2 @@ }

}
function commitWorkbenchBatch(target, allowedPaths, { operation, message }) {
const paths = uniqueValues(allowedPaths || []);
const context = beginGovernanceSync(target, {
operation,
allowDirtyWorktree: true,
allowedRelativePaths: paths,
allowDirtyWriteScope: true,
});
try {
return commitGovernanceSync(context, paths, { message });
}
finally {
releaseGovernanceSync(context);
}
}
function uniqueValues(values) {

@@ -388,47 +362,50 @@ const seen = new Set();

}
function isTaskInReviewQueue(task) {
return task?.reviewQueueState === "ready-to-confirm" && Array.isArray(task?.taskQueues) && task.taskQueues.includes("review");
function buildWorkbenchTaskCache(repository, projectRoot) {
const tasks = repository.list();
const byId = new Map();
const byDirectory = new Map();
for (const task of tasks) {
for (const id of [task.id, task.taskKey, task.shortId].filter(Boolean))
byId.set(String(id), task);
byDirectory.set(normalizeWorkbenchPath(taskDirectoryForCachedTask(projectRoot, task)), task);
}
return { byId, byDirectory };
}
function taskHasPendingLessonWork(task) {
const queues = Array.isArray(task?.taskQueues) ? task.taskQueues : [];
const candidateRows = Array.isArray(task?.lessonCandidateRows) ? task.lessonCandidateRows : [];
return queues.includes("lessons") ||
task?.lessonCandidateStatus === "needs-promotion" ||
task?.lessonCandidatePromotionState === "queued" ||
candidateRows.some((candidate) => ["ready-for-review", "needs-promotion"].includes(String(candidate?.status || "")));
function findCachedWorkbenchTask(cache, taskId) {
return cache.byId.get(String(taskId || ""));
}
function taskReadyForCloseout(task) {
if (!task)
return false;
if (task.reviewStatus !== "confirmed")
return false;
if (task.closeoutStatus === "closed")
return false;
if (taskHasPendingLessonWork(task))
return false;
return ["no-candidate-accepted", "promoted", "rejected"].includes(String(task.lessonCandidateStatus || ""));
function findCachedTaskByDirectory(cache, taskDir) {
return cache.byDirectory.get(normalizeWorkbenchPath(taskDir));
}
function reviewQueueRejectionPayload(task) {
return {
error: "Review completion is only available for tasks in the review queue.",
reviewQueueState: task?.reviewQueueState || "unknown",
taskQueues: Array.isArray(task?.taskQueues) ? task.taskQueues : [],
queueReasons: Array.isArray(task?.queueReasons) ? task.queueReasons : [],
repairPrompt: task?.repairPrompt || "",
reviewStatus: task?.reviewStatus || "unknown",
taskId: task?.id || "",
};
function taskDirectoryForCachedTask(projectRoot, task) {
const raw = String(task.currentPath || task.path || "");
if (!raw)
throw new Error(`Task has no currentPath/path: ${task.id || task.taskKey || "unknown"}`);
const withoutTarget = raw.replace(/^TARGET:/, "");
return path.isAbsolute(withoutTarget) ? withoutTarget : path.join(projectRoot, withoutTarget.replace(/^\/+/, ""));
}
function closeoutRejectionPayload(task, error = "Task closeout is only available after human review is confirmed and Lesson routing is complete.") {
return {
error,
closeoutStatus: task?.closeoutStatus || "unknown",
lifecycleState: task?.lifecycleState || "unknown",
reviewStatus: task?.reviewStatus || "unknown",
taskQueues: Array.isArray(task?.taskQueues) ? task.taskQueues : [],
lessonCandidateStatus: task?.lessonCandidateStatus || "unknown",
lessonCandidatePromotionState: task?.lessonCandidatePromotionState || "unknown",
taskId: task?.id || "",
};
function normalizeWorkbenchPath(filePath) {
return toPosix(path.resolve(filePath));
}
function bulkReviewConfirmationBlock(task) {
if (task.reviewStatus === "confirmed" || task.reviewConfirmation?.confirmed === true) {
return { status: 409, reason: "Review is already confirmed.", payload: { reviewStatus: task.reviewStatus || "confirmed", taskId: task.id || "" } };
}
const queues = Array.isArray(task.taskQueues) ? task.taskQueues : [];
if (task.reviewQueueState !== "ready-to-confirm" || !queues.includes("review")) {
return {
status: 409,
reason: "Review completion is only available for tasks in the review queue.",
payload: {
reviewQueueState: task.reviewQueueState || "unknown",
taskQueues: queues,
queueReasons: Array.isArray(task.queueReasons) ? task.queueReasons : [],
repairPrompt: task.repairPrompt || "",
reviewStatus: task.reviewStatus || "unknown",
taskId: task.id || "",
},
};
}
return null;
}
function startPollingWatch(roots, regenerate) {

@@ -435,0 +412,0 @@ let lastMtime = latestTreeMtime(roots);

@@ -6,4 +6,4 @@ // Governance index rendering stays behavior-first until task/governance surface types are modeled.

import { splitMarkdownRow } from "./markdown-utils.mjs";
import { collectTasks } from "./task-scanner.mjs";
import { buildTaskIndex } from "./task-index.mjs";
import { createScannerTaskRepository } from "./task-repository.mjs";
import { beginGovernanceSync, commitGovernanceSync, moduleGeneratedIndexSurfaces, releaseGovernanceSync, } from "./governance-sync.mjs";

@@ -16,3 +16,3 @@ import { markdownCell } from "./task-lifecycle/text-utils.mjs";

try {
const tasks = collectTasks(target)
const tasks = createScannerTaskRepository(target).list({ includeArchived: true })
.filter((task) => task.deletionState !== "deleted")

@@ -94,5 +94,5 @@ .sort((a, b) => String(a.id).localeCompare(String(b.id)));

task.title || task.shortId || task.id,
task.state,
task.lifecycleState,
task.reviewStatus,
taskLifecycleProjection(task).state,
taskLifecycleProjection(task).lifecycleState,
taskLifecycleProjection(task).reviewStatus,
task.closeoutStatus,

@@ -121,5 +121,5 @@ task.walkthroughPath || "pending",

task.taskKey || task.id,
task.closeoutStatus || "missing",
taskLifecycleProjection(task).closeoutStatus || "missing",
stripTarget(task.walkthroughPath) || "pending",
task.reviewStatus || "pending",
taskLifecycleProjection(task).reviewStatus || "pending",
task.lessonCandidateDecisionComplete ? "checked" : (task.lessonCandidateStatus || "pending"),

@@ -194,6 +194,6 @@ residual(task),

task.title || task.shortId || task.id,
mapLedgerState(task.state),
mapLedgerState(taskLifecycleProjection(task).state),
Array.isArray(task.taskQueues) && task.taskQueues.length ? task.taskQueues.join(",") : "none",
plan,
task.reviewStatus === "confirmed" ? stripTarget(task.reviewPath) : (task.reviewStatus || "pending"),
taskLifecycleProjection(task).reviewStatus === "confirmed" ? stripTarget(task.reviewPath) : (taskLifecycleProjection(task).reviewStatus || "pending"),
task.lessonCandidateDecisionComplete ? "checked" : (task.lessonCandidateStatus || "pending"),

@@ -221,2 +221,19 @@ task.walkthroughPath ? stripTarget(task.walkthroughPath) : (task.closeoutStatus || "pending"),

}
function taskLifecycleProjection(task) {
const projection = task.taskLifecycleProjection;
if (projection && typeof projection === "object")
return projection;
return {
state: String(task.state || "unknown"),
lifecycleState: String(task.lifecycleState || "unknown"),
reviewStatus: String(task.reviewStatus || "missing"),
reviewQueueState: String(task.reviewQueueState || "not-in-queue"),
closeoutStatus: String(task.closeoutStatus || "missing"),
taskQueues: Array.isArray(task.taskQueues) ? task.taskQueues : ["active"],
materialsReady: task.materialsReady === true,
reviewSubmitted: task.reviewSubmitted === true,
lessonCandidateDecisionComplete: task.lessonCandidateDecisionComplete === true,
deletionState: String(task.deletionState || "active"),
};
}
function mapLedgerState(state) {

@@ -223,0 +240,0 @@ if (state === "in_progress")

@@ -15,3 +15,5 @@ export * from "./core-shared.mjs";

export * from "./governance-index-generator.mjs";
export * from "./harness-transaction.mjs";
export * from "./task-lifecycle.mjs";
export * from "../application/task/task-operations.mjs";
export * from "./module-registry.mjs";

@@ -18,0 +20,0 @@ export * from "./task-lesson-sedimentation.mjs";

import fs from "node:fs";
import path from "node:path";
import { assertRenderableHarnessManifest, readHarnessManifest, renderHarnessManifest } from "../infrastructure/kernel/manifest-io.mjs";
import { toPosix } from "../infrastructure/kernel/path-utils.mjs";
export { assertRenderableHarnessManifest, renderHarnessManifest } from "../infrastructure/kernel/manifest-io.mjs";
export { toPosix } from "../infrastructure/kernel/path-utils.mjs";
export const v2HarnessRoot = "coding-agent-harness";

@@ -159,5 +163,2 @@ export const legacyPlanningRoot = ["docs", "09-PLANNING"];

}
export function toPosix(value) {
return String(value).split(path.sep).join("/");
}
function normalizeTargetShape(input = ".") {

@@ -280,151 +281,2 @@ if (input && typeof input === "object" && input.projectRoot) {

}
function readHarnessManifest(manifestPath) {
if (!fs.existsSync(manifestPath))
return null;
const manifest = { version: 2, locale: "en-US", capabilities: [], structure: {} };
let section = "";
let inModuleItems = false;
let currentModuleKey = "";
let currentModuleListField = "";
for (const rawLine of fs.readFileSync(manifestPath, "utf8").split(/\r?\n/)) {
const line = rawLine.replace(/\s+#.*$/, "");
if (!line.trim())
continue;
const top = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (top) {
section = top[1];
inModuleItems = false;
currentModuleKey = "";
currentModuleListField = "";
if (section === "version")
manifest.version = Number(top[2]) || 2;
else if (section === "locale")
manifest.locale = top[2] || "en-US";
else if (section === "modules")
manifest.modules = { items: {} };
else if (section !== "structure" && section !== "capabilities")
manifest[section] = top[2];
continue;
}
const listItem = line.match(/^\s*-\s*(.+)$/);
if (section === "capabilities" && listItem) {
manifest.capabilities.push(listItem[1].trim());
continue;
}
const nested = line.match(/^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.+)$/);
if (section === "structure" && nested)
manifest.structure[nested[1]] = nested[2].trim();
if (section === "modules") {
if (!manifest.modules)
manifest.modules = { items: {} };
const moduleTop = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (moduleTop) {
currentModuleKey = "";
currentModuleListField = "";
if (moduleTop[1] === "items")
inModuleItems = true;
else if (moduleTop[1] === "schema")
manifest.modules.schema = moduleTop[2].trim();
else if (moduleTop[1] === "generatedView")
manifest.modules.generatedView = moduleTop[2].trim();
else
manifest.modules[moduleTop[1]] = moduleTop[2].trim();
continue;
}
const moduleItem = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (inModuleItems && moduleItem) {
currentModuleKey = moduleItem[1];
currentModuleListField = "";
if (!manifest.modules.items[currentModuleKey])
manifest.modules.items[currentModuleKey] = {};
continue;
}
const moduleField = line.match(/^ ([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (inModuleItems && currentModuleKey && moduleField) {
const field = moduleField[1];
const raw = moduleField[2].trim();
if (["scope", "shared", "dependsOn"].includes(field)) {
manifest.modules.items[currentModuleKey][field] = raw === "[]" ? [] : raw ? [raw] : [];
currentModuleListField = field;
}
else {
manifest.modules.items[currentModuleKey][field] = raw;
currentModuleListField = "";
}
continue;
}
const moduleListItem = line.match(/^ -\s*(.+)$/);
if (inModuleItems && currentModuleKey && currentModuleListField && moduleListItem) {
const existing = manifest.modules.items[currentModuleKey][currentModuleListField];
manifest.modules.items[currentModuleKey][currentModuleListField] = [
...(Array.isArray(existing) ? existing : []),
moduleListItem[1].trim(),
];
}
}
}
if (!manifest.structure.harnessRoot && manifest.harnessRoot)
manifest.structure.harnessRoot = manifest.harnessRoot;
if (!manifest.structure.planningRoot && manifest.harnessRoot)
manifest.structure.planningRoot = `${manifest.harnessRoot}/planning`;
return manifest;
}
export function renderHarnessManifest({ locale, capabilities, structure = null, modules = null }) {
const manifestStructure = structure || {
harnessRoot: v2HarnessRoot,
planningRoot: `${v2HarnessRoot}/planning`,
tasksRoot: `${v2HarnessRoot}/planning/tasks`,
modulesRoot: `${v2HarnessRoot}/planning/modules`,
externalRoot: `${v2HarnessRoot}/planning/external`,
governanceRoot: `${v2HarnessRoot}/governance`,
generatedRoot: `${v2HarnessRoot}/governance/generated`,
};
const lines = [
"version: 2",
`locale: ${locale}`,
"capabilities:",
...capabilities.map((capability) => ` - ${capability}`),
"structure:",
...Object.entries(manifestStructure).map(([key, value]) => ` ${key}: ${value}`),
];
if (modules && (modules.schema || modules.generatedView || Object.keys(modules.items || {}).length > 0)) {
lines.push("modules:");
if (modules.schema)
lines.push(` schema: ${yamlScalar(modules.schema)}`);
if (modules.generatedView)
lines.push(` generatedView: ${yamlScalar(modules.generatedView)}`);
lines.push(" items:");
for (const [key, module] of Object.entries(modules.items || {}).sort(([left], [right]) => left.localeCompare(right))) {
lines.push(` ${key}:`);
for (const field of ["title", "prefix", "status", "branch", "owner", "currentStep", "scope", "shared", "dependsOn", "plan", "brief", "updated"]) {
const value = module[field];
if (Array.isArray(value)) {
lines.push(` ${field}:${value.length ? "" : " []"}`);
for (const item of value)
lines.push(` - ${yamlScalar(String(item))}`);
}
else if (value !== undefined && value !== null && String(value) !== "") {
lines.push(` ${field}: ${yamlScalar(String(value))}`);
}
}
}
}
return `${lines.join("\n")}\n`;
}
export function assertRenderableHarnessManifest(manifest) {
if (!manifest)
return;
const allowed = new Set(["version", "locale", "capabilities", "structure", "modules", "harnessRoot"]);
const unknown = Object.keys(manifest).filter((key) => !allowed.has(key));
if (unknown.length > 0)
throw new Error(`Cannot rewrite harness.yaml with unknown top-level fields: ${unknown.join(", ")}`);
}
function yamlScalar(value) {
const raw = String(value || "");
if (!raw)
return "''";
if (/[:#\n\r]|^\s|\s$|^(?:true|false|null|\d+(?:\.\d+)?)$/i.test(raw))
return JSON.stringify(raw);
return raw;
}
function resolveManifestStructurePath(projectRoot, fieldName, relativePath) {

@@ -431,0 +283,0 @@ const raw = String(relativePath || "").trim();

@@ -8,3 +8,3 @@ // Generic preset entrypoint runner. Domain logic belongs in preset packages.

import { absoluteHarnessPathContext, harnessPathContext, normalizeTarget, readFileSafe, readJsonSafe, renderHarnessTemplate, sanitizeDeep, toPosix, walkFiles, } from "./core-shared.mjs";
import { beginGovernanceSync, commitGovernanceSync, releaseGovernanceSync } from "./governance-sync.mjs";
import { assertTransactionSucceeded, createGovernanceHarnessTransaction } from "./harness-transaction.mjs";
import { parseTaskMetadata } from "./task-metadata.mjs";

@@ -96,30 +96,21 @@ import { taskIdForDirectory } from "./task-scanner.mjs";

const materialization = validateMaterializationManifest(preset, entrypoint, manifest, { outputRoot, target, entrypointName });
const governanceContext = beginGovernanceSync(target, {
const transactionResult = applyPresetMaterializationTransaction(target, {
operation: `preset-run ${preset.id}.${entrypointName}`,
allowDirtyWorktree: true,
allowedRelativePaths: materialization.map((item) => item.destination),
message: `chore(harness): run preset ${preset.id} ${entrypointName}`,
materialization,
});
try {
materializeWrites(target.projectRoot, materialization);
const commit = commitGovernanceSync(governanceContext, materialization.map((item) => item.destination), {
message: `chore(harness): run preset ${preset.id} ${entrypointName}`,
});
return {
preset: preset.id,
entrypoint: entrypointName,
taskId,
status: manifest.status || (entrypoint.type === "check" ? "pass" : "ok"),
materialized: materialization.map((item) => ({
source: item.source,
destination: item.destination,
type: item.type,
sha256: item.sha256,
})),
governance: { commit },
presetDrift,
};
}
finally {
releaseGovernanceSync(governanceContext);
}
return {
preset: preset.id,
entrypoint: entrypointName,
taskId,
status: manifest.status || (entrypoint.type === "check" ? "pass" : "ok"),
materialized: materialization.map((item) => ({
source: item.source,
destination: item.destination,
type: item.type,
sha256: item.sha256,
})),
governance: { commit: transactionResult.commit, transaction: presetTransactionSummary(transactionResult) },
presetDrift,
};
}

@@ -163,9 +154,3 @@ finally {

const contextPath = path.join(outputRoot, "preset-action-context.json");
let governanceContext = null;
try {
governanceContext = beginGovernanceSync(target, {
operation: `preset-action ${preset.id}.${actionName}`,
allowDirtyWorktree: true,
allowedRelativePaths: concreteActionWriteScopes(actionWriteScopes),
});
const beforeSnapshot = targetSnapshot(target.projectRoot);

@@ -225,5 +210,6 @@ const context = {

}, manifest, { outputRoot, target, entrypointName: actionName });
materializeWrites(target.projectRoot, materialization);
const commit = commitGovernanceSync(governanceContext, materialization.map((item) => item.destination), {
const transactionResult = applyPresetMaterializationTransaction(target, {
operation: `preset-action ${preset.id}.${actionName}`,
message: `chore(harness): run preset action ${preset.id} ${actionName}`,
materialization,
});

@@ -243,3 +229,3 @@ return {

})),
governance: { commit },
governance: { commit: transactionResult.commit, transaction: presetTransactionSummary(transactionResult) },
presetDrift,

@@ -249,4 +235,2 @@ };

finally {
if (governanceContext)
releaseGovernanceSync(governanceContext);
fs.rmSync(outputRoot, { recursive: true, force: true });

@@ -349,5 +333,2 @@ }

}
function concreteActionWriteScopes(scopes) {
return scopes.filter((scope) => !scope.endsWith("/**"));
}
function presetScriptEnv(contextPath) {

@@ -492,41 +473,2 @@ const env = { HARNESS_PRESET_CONTEXT: contextPath };

}
function materializeWrites(targetRoot, writes) {
const backups = [];
try {
for (const write of writes) {
const destinationPath = write.destinationPath;
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const existed = fs.existsSync(destinationPath);
const backupPath = existed ? `${destinationPath}.backup-${process.pid}-${crypto.randomBytes(4).toString("hex")}` : "";
if (existed) {
fs.copyFileSync(destinationPath, backupPath);
backups.push({ destinationPath, backupPath, existed });
}
else {
backups.push({ destinationPath, backupPath: "", existed });
}
const tempPath = `${destinationPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
fs.copyFileSync(write.sourcePath, tempPath);
fs.renameSync(tempPath, destinationPath);
}
for (const backup of backups) {
if (backup.backupPath)
fs.rmSync(backup.backupPath, { force: true });
}
}
catch (error) {
for (const backup of backups.reverse()) {
try {
if (backup.existed && backup.backupPath && fs.existsSync(backup.backupPath))
fs.renameSync(backup.backupPath, backup.destinationPath);
else if (!backup.existed)
fs.rmSync(backup.destinationPath, { force: true });
}
catch {
// Preserve the original materialization failure.
}
}
throw error;
}
}
function targetSnapshot(root) {

@@ -569,1 +511,32 @@ const entries = new Map();

}
function applyPresetMaterializationTransaction(target, { operation, message, materialization }) {
const transaction = createGovernanceHarnessTransaction(target);
const writes = materialization.map((item) => ({
path: item.destination,
content: fs.readFileSync(item.sourcePath),
action: "materialize-preset-write",
surface: "preset-materialization",
}));
const plan = transaction.plan({
operation,
writes,
allowedPaths: materialization.map((item) => item.destination),
commit: {
message,
allowDirtyWorktree: true,
},
});
const result = transaction.apply(plan);
assertTransactionSucceeded(result);
return result;
}
function presetTransactionSummary(result) {
return {
operation: result.operation,
dryRun: result.dryRun,
allowedPaths: result.allowedPaths,
writes: result.writes,
generatedSurfaces: result.generatedSurfaces,
success: result.success,
};
}

@@ -31,5 +31,6 @@ import { isConcreteAuditField } from "./task-audit-metadata.mjs";

return "tasks with open blocking review findings cannot be archived without an explicit human waiver";
if (task.state !== "done")
const reviewFinalized = task.reviewStatus === "confirmed" || task.reviewConfirmation?.confirmed === true || (task.taskQueues || []).includes("finalized");
if (!reviewFinalized && task.state !== "done")
return `state:${task.state || "unknown"}`;
if (task.budget !== "simple" && task.closeoutStatus !== "closed")
if (!reviewFinalized && task.budget !== "simple" && task.closeoutStatus !== "closed")
return "tasks must have closed closeout materials before archive";

@@ -36,0 +37,0 @@ if (task.materialsReady === false && task.reviewStatus !== "confirmed") {

@@ -5,8 +5,7 @@ import fs from "node:fs";

import { normalizeTarget, readFileSafe, toPosix, } from "./core-shared.mjs";
import { collectTasks } from "./task-scanner.mjs";
import { createScannerTaskRepository } from "./task-repository.mjs";
import { taskScannerVersion } from "./task-review-model.mjs";
const collectTasksForIndex = collectTasks;
export function buildTaskIndex(targetInput) {
const target = normalizeTarget(targetInput);
const tasks = collectTasksForIndex(target);
const tasks = createScannerTaskRepository(target).list();
assertUniqueTaskKeys(tasks);

@@ -13,0 +12,0 @@ return {

@@ -9,3 +9,3 @@ import fs from "node:fs";

import { firstColumn, updateMarkdownTableRow } from "./markdown-utils.mjs";
import { taskIdForDirectory } from "./task-scanner.mjs";
import { listTaskPlanPaths, taskIdForDirectory } from "./task-scanner.mjs";
import { beginGovernanceSync, commitGovernanceSync, governanceRelativePaths, releaseGovernanceSync, } from "./governance-sync.mjs";

@@ -203,3 +203,4 @@ const presetId = "lesson-sedimentation";

}
const entries = normalizedSelections.map((selection) => resolveLessonCandidate(target, selection.taskId, selection.candidateId));
const taskDirectoryIndex = buildLessonTaskDirectoryIndex(target);
const entries = normalizedSelections.map((selection) => resolveLessonCandidate(target, selection.taskId, selection.candidateId, taskDirectoryIndex));
const sourceShort = entries.length === 1

@@ -312,4 +313,6 @@ ? entries[0].sourceShortId.replace(/^\d{4}-\d{2}-\d{2}-/, "")

}
function resolveLessonCandidate(target, taskRef, candidateId) {
const sourceTaskDir = resolveTaskDirectory(target, taskRef);
function resolveLessonCandidate(target, taskRef, candidateId, taskDirectoryIndex) {
const sourceTaskDir = taskDirectoryIndex
? resolveLessonTaskDirectory(target, taskDirectoryIndex, taskRef)
: resolveTaskDirectory(target, taskRef);
const sourceTaskId = taskIdForDirectory(target, sourceTaskDir);

@@ -355,2 +358,72 @@ const sourceShortId = path.basename(sourceTaskDir);

}
function buildLessonTaskDirectoryIndex(target) {
const byRef = new Map();
const directories = listTaskPlanPaths(target).map((taskPlanPath) => path.dirname(taskPlanPath));
for (const taskDir of directories) {
const sourceTaskId = taskIdForDirectory(target, taskDir);
const sourceShortId = path.basename(taskDir);
for (const ref of lessonTaskRefs(sourceTaskId, sourceShortId, taskDir, target.projectRoot)) {
const normalized = normalizeLessonTaskRef(ref);
if (!normalized || byRef.has(normalized))
continue;
byRef.set(normalized, taskDir);
}
}
return { byRef, directories };
}
function resolveLessonTaskDirectory(target, index, taskRef) {
const absolute = absoluteLessonTaskRef(target, taskRef);
if (absolute && fs.existsSync(path.join(absolute, "task_plan.md")))
return absolute;
const raw = normalizeLessonTaskRef(taskRef);
const direct = raw ? index.byRef.get(raw) : undefined;
if (direct)
return direct;
const normalized = normalizeTaskId(raw);
const normalizedMatch = normalized ? index.byRef.get(normalized) : undefined;
if (normalizedMatch)
return normalizedMatch;
const datedMatches = index.directories.filter((taskDir) => {
const dirName = path.basename(taskDir);
return /^\d{4}-\d{2}-\d{2}-/.test(dirName) && dirName.replace(/^\d{4}-\d{2}-\d{2}-/, "") === normalized;
});
if (datedMatches.length === 1)
return datedMatches[0];
if (datedMatches.length > 1) {
const options = datedMatches.map((taskDir) => `- ${taskIdForDirectory(target, taskDir)}`).join("\n");
throw new Error(`Ambiguous task reference: ${taskRef}\n${options}`);
}
throw new Error(`Task not found: ${taskRef}`);
}
function lessonTaskRefs(sourceTaskId, sourceShortId, taskDir, projectRoot) {
const relative = toPosix(path.relative(projectRoot, taskDir));
const undatedShortId = sourceShortId.replace(/^\d{4}-\d{2}-\d{2}-/, "");
const refs = [
sourceTaskId,
sourceShortId,
normalizeTaskId(sourceShortId),
undatedShortId,
normalizeTaskId(undatedShortId),
relative,
`TARGET:${relative}`,
];
const parts = sourceTaskId.split("/");
if (parts.length > 1)
refs.push(parts.at(-1) || "");
return refs.filter(Boolean);
}
function absoluteLessonTaskRef(target, taskRef) {
const withoutTarget = String(taskRef || "").replace(/^TARGET:/, "");
if (!withoutTarget)
return "";
return path.isAbsolute(withoutTarget) ? withoutTarget : path.join(target.projectRoot, withoutTarget.replace(/^\/+/, ""));
}
function normalizeLessonTaskRef(taskRef) {
return String(taskRef || "")
.replace(/^TARGET:/, "")
.replace(/^coding-agent-harness\/planning\//, "")
.replace(/^planning\//, "")
.replace(/^docs\/09-PLANNING\//, "")
.replace(/^\/+/, "");
}
function commonSourceShort(entries) {

@@ -357,0 +430,0 @@ const unique = [...new Set(entries.map((entry) => entry.sourceShortId.replace(/^\d{4}-\d{2}-\d{2}-/, "")))];

@@ -21,2 +21,3 @@ import fs from "node:fs";

import { beginGovernanceSync, commitGovernanceSync, governanceRelativePaths, releaseGovernanceSync, syncModuleStepGovernance, syncTaskGovernance } from "./governance-sync.mjs";
import { assertTransactionSucceeded, createGovernanceHarnessTransaction } from "./harness-transaction.mjs";
import { normalizeHarnessModuleKey, prepareModuleRegistration, prepareModuleStepRegistrationUpdate, readHarnessModules, registeredHarnessModule } from "./module-registry.mjs";

@@ -384,56 +385,67 @@ import { assertLifecyclePresetWriteScope, buildLifecyclePresetContext, evaluatePresetValues, renderLifecyclePresetTaskTemplate, resolveLifecyclePresetInputs } from "./task-lifecycle/preset-interop.mjs";

validateReviewEntryGate(taskDir, budget);
const governanceContext = beginGovernanceSync(target, { operation: `${event} ${canonicalTaskId}` });
try {
let content = readFileSafe(progressPath);
if (normalizedState)
content = updateProgressState(content, normalizedState, registry.locale || "en-US");
content = appendProgressLog(content, { event, message, evidence });
fs.writeFileSync(progressPath, content.endsWith("\n") ? content : `${content}\n`);
const allowedPaths = [toPosix(path.relative(target.projectRoot, progressPath))];
const advancedPhasePath = advanceLifecyclePhase(target, taskDir, normalizedEvent);
if (advancedPhasePath)
allowedPaths.push(advancedPhasePath);
if (event === "task-review") {
const reviewPath = path.join(taskDir, "review.md");
const reviewContent = readFileSafe(reviewPath);
fs.writeFileSync(reviewPath, replaceAgentReviewSubmission(reviewContent, renderAgentReviewSubmission({
target,
taskDir,
canonicalTaskId,
message,
evidence,
})));
allowedPaths.push(toPosix(path.relative(target.projectRoot, reviewPath)));
const lessonDecisionPath = autoRecordNoLessonCandidateDecision(target, taskDir);
if (lessonDecisionPath)
allowedPaths.push(lessonDecisionPath);
}
if (event === "task-complete" && target.harness.version === 2) {
const walkthroughPath = path.join(taskDir, "walkthrough.md");
const currentWalkthrough = readFileSafe(walkthroughPath) || `# Walkthrough: ${canonicalTaskId}\n`;
fs.writeFileSync(walkthroughPath, markWalkthroughClosed(currentWalkthrough));
allowedPaths.push(toPosix(path.relative(target.projectRoot, walkthroughPath)));
}
const task = findTaskByDirectory(target, taskDir) ||
{
id: canonicalTaskId,
shortId: path.basename(taskDir),
title: canonicalTaskId,
path: `TARGET:${toPosix(path.relative(target.projectRoot, taskDir))}`,
state: normalizedState || currentTask?.state || "unknown",
const lifecycleResultBox = { current: null };
const transaction = createGovernanceHarnessTransaction(target);
const plan = transaction.plan({
operation: `${event} ${canonicalTaskId}`,
commit: { message: `chore(harness): advance task ${canonicalTaskId}` },
apply() {
let content = readFileSafe(progressPath);
if (normalizedState)
content = updateProgressState(content, normalizedState, registry.locale || "en-US");
content = appendProgressLog(content, { event, message, evidence });
fs.writeFileSync(progressPath, content.endsWith("\n") ? content : `${content}\n`);
const allowedPaths = [toPosix(path.relative(target.projectRoot, progressPath))];
const advancedPhasePath = advanceLifecyclePhase(target, taskDir, normalizedEvent);
if (advancedPhasePath)
allowedPaths.push(advancedPhasePath);
if (event === "task-review") {
const reviewPath = path.join(taskDir, "review.md");
const reviewContent = readFileSafe(reviewPath);
fs.writeFileSync(reviewPath, replaceAgentReviewSubmission(reviewContent, renderAgentReviewSubmission({
target,
taskDir,
canonicalTaskId,
message,
evidence,
})));
allowedPaths.push(toPosix(path.relative(target.projectRoot, reviewPath)));
const lessonDecisionPath = autoRecordNoLessonCandidateDecision(target, taskDir);
if (lessonDecisionPath)
allowedPaths.push(lessonDecisionPath);
}
if (event === "task-complete" && target.harness.version === 2) {
const walkthroughPath = path.join(taskDir, "walkthrough.md");
const currentWalkthrough = readFileSafe(walkthroughPath) || `# Walkthrough: ${canonicalTaskId}\n`;
fs.writeFileSync(walkthroughPath, markWalkthroughClosed(currentWalkthrough));
allowedPaths.push(toPosix(path.relative(target.projectRoot, walkthroughPath)));
}
const task = findTaskByDirectory(target, taskDir) ||
{
id: canonicalTaskId,
shortId: path.basename(taskDir),
title: canonicalTaskId,
path: `TARGET:${toPosix(path.relative(target.projectRoot, taskDir))}`,
state: normalizedState || currentTask?.state || "unknown",
};
const governanceState = normalizedState || task.state || currentTask?.state || "planned";
const governance = syncTaskGovernance(target, task, { event, state: governanceState, message, dryRun: false });
const governancePaths = governanceRelativePaths(governance.changes);
lifecycleResultBox.current = { event, task, governance };
return {
allowedPaths: [...allowedPaths, ...governancePaths],
generatedSurfaces: governance.changes.map((change) => ({ surface: change.surface, paths: [change.destination] })),
commit: { message: `chore(harness): advance task ${canonicalTaskId} to ${governanceState}` },
};
const governanceState = normalizedState || task.state || currentTask?.state || "planned";
const governance = syncTaskGovernance(target, task, { event, state: governanceState, message, dryRun: false });
const commit = commitGovernanceSync(governanceContext, [...allowedPaths, ...governanceRelativePaths(governance.changes)], {
message: `chore(harness): advance task ${canonicalTaskId} to ${governanceState}`,
});
return {
event,
task,
governance: { ...governance, commit },
};
}
finally {
releaseGovernanceSync(governanceContext);
}
},
});
const transactionResult = transaction.apply(plan);
assertTransactionSucceeded(transactionResult);
const lifecycleResult = lifecycleResultBox.current;
if (!lifecycleResult)
throw new Error(`Lifecycle transaction did not produce a result: ${canonicalTaskId}`);
return {
event: lifecycleResult.event,
task: lifecycleResult.task,
governance: { ...lifecycleResult.governance, commit: transactionResult.commit },
};
}

@@ -497,14 +509,19 @@ function assertLifecycleEventStateConsistency(event, state) {

throw new Error(`Phase not found: ${phaseId}`);
const governanceContext = beginGovernanceSync(target, { operation: `task-phase ${taskId} ${phaseId}` });
try {
content = phaseUpdate.content;
fs.writeFileSync(visualMapPath, content);
const commit = commitGovernanceSync(governanceContext, [toPosix(path.relative(target.projectRoot, visualMapPath))], {
message: `chore(harness): update task phase ${taskId} ${phaseId}`,
});
return { event: "task-phase", task: findTaskByDirectory(target, taskDir), phaseId, governance: { commit } };
}
finally {
releaseGovernanceSync(governanceContext);
}
const visualMapRelative = toPosix(path.relative(target.projectRoot, visualMapPath));
const transaction = createGovernanceHarnessTransaction(target);
let taskAfterUpdate;
const plan = transaction.plan({
operation: `task-phase ${taskId} ${phaseId}`,
allowedPaths: [visualMapRelative],
commit: { message: `chore(harness): update task phase ${taskId} ${phaseId}` },
apply() {
content = phaseUpdate.content;
fs.writeFileSync(visualMapPath, content);
taskAfterUpdate = findTaskByDirectory(target, taskDir);
return { allowedPaths: [visualMapRelative] };
},
});
const transactionResult = transaction.apply(plan);
assertTransactionSucceeded(transactionResult);
return { event: "task-phase", task: taskAfterUpdate || findTaskByDirectory(target, taskDir), phaseId, governance: { commit: transactionResult.commit } };
}

@@ -533,18 +550,46 @@ export function updateModuleStep(targetInput, moduleKey, stepId, { state = "" } = {}) {

throw new Error(`Module step not found: ${stepId}`);
const governanceContext = beginGovernanceSync(target, { operation: `module-step ${normalizedModuleKey} ${stepId}` });
try {
content = stepUpdate.content;
fs.writeFileSync(modulePlanPath, content);
const moduleRegistration = prepareModuleStepRegistrationUpdate(target, normalizedModuleKey, { stepId, state: normalizedState });
const governance = syncModuleStepGovernance(target, { moduleKey: normalizedModuleKey, stepId, state: normalizedState });
const commit = commitGovernanceSync(governanceContext, [
toPosix(path.relative(target.projectRoot, modulePlanPath)),
...governanceRelativePaths(moduleRegistration.changes),
...governanceRelativePaths(governance.changes),
], { message: `chore(harness): update module ${normalizedModuleKey} step ${stepId}` });
return { event: "module-step", moduleKey: normalizedModuleKey, stepId, state: normalizedState, governance: { ...governance, commit } };
}
finally {
releaseGovernanceSync(governanceContext);
}
const modulePlanRelative = toPosix(path.relative(target.projectRoot, modulePlanPath));
const plannedModuleRegistration = prepareModuleStepRegistrationUpdate(target, normalizedModuleKey, { stepId, state: normalizedState, dryRun: true });
const plannedGovernance = syncModuleStepGovernance(target, { moduleKey: normalizedModuleKey, stepId, state: normalizedState, dryRun: true });
const plannedGeneratedSurfaces = [...plannedModuleRegistration.changes, ...plannedGovernance.changes].map((change) => ({
surface: change.surface,
paths: [change.destination],
}));
const allowedPaths = [
modulePlanRelative,
...governanceRelativePaths(plannedModuleRegistration.changes),
...governanceRelativePaths(plannedGovernance.changes),
];
const governanceResultBox = {};
const transaction = createGovernanceHarnessTransaction(target);
const plan = transaction.plan({
operation: `module-step ${normalizedModuleKey} ${stepId}`,
allowedPaths,
generatedSurfaces: plannedGeneratedSurfaces,
commit: { message: `chore(harness): update module ${normalizedModuleKey} step ${stepId}` },
apply() {
content = stepUpdate.content;
fs.writeFileSync(modulePlanPath, content);
const moduleRegistration = prepareModuleStepRegistrationUpdate(target, normalizedModuleKey, { stepId, state: normalizedState });
const governance = syncModuleStepGovernance(target, { moduleKey: normalizedModuleKey, stepId, state: normalizedState });
governanceResultBox.current = governance;
return {
allowedPaths: [
modulePlanRelative,
...governanceRelativePaths(moduleRegistration.changes),
...governanceRelativePaths(governance.changes),
],
generatedSurfaces: [...moduleRegistration.changes, ...governance.changes].map((change) => ({
surface: change.surface,
paths: [change.destination],
})),
};
},
});
const transactionResult = transaction.apply(plan);
assertTransactionSucceeded(transactionResult);
const finalGovernance = governanceResultBox.current;
if (!finalGovernance)
throw new Error(`Module step transaction did not produce governance changes: ${normalizedModuleKey} ${stepId}`);
return { event: "module-step", moduleKey: normalizedModuleKey, stepId, state: normalizedState, governance: { changes: finalGovernance.changes, commit: transactionResult.commit } };
}

@@ -551,0 +596,0 @@ export function listLifecycleTasks(targetInput, { state = "", moduleKey = "", queue = "", preset = "", review = "", lesson = "", search = "", missingMaterials = false, includeArchived = false } = {}) {

@@ -257,14 +257,16 @@ // Dynamic review queue modeling stays behavior-first until the metadata domain model PR.

if (reviewStatus === "confirmed") {
if (closeoutStatus === "closed")
taskQueues.push("finalized");
else {
taskQueues.push("confirmed");
if (!hasLessonWork)
taskQueues.push("confirmed-finalization-pending");
}
taskQueues.push("finalized");
}
}
const normalizedTaskQueues = [...new Set(taskQueues)];
if (normalizedTaskQueues.length === 0)
normalizedTaskQueues.push("active");
if (normalizedTaskQueues.length === 0) {
if (state === "in_progress")
normalizedTaskQueues.push("active");
else if (["planned", "not_started"].includes(state))
normalizedTaskQueues.push("planned");
else if (state === "done")
normalizedTaskQueues.push("done");
else
normalizedTaskQueues.push("unknown");
}
return {

@@ -347,3 +349,3 @@ taskQueues: normalizedTaskQueues,

if (reviewStatus === "confirmed")
return hasPendingLessonWork(lessonCandidates) ? "lesson-finalization-pending" : "confirmed-finalization-pending";
return "finalized";
if (state === "blocked")

@@ -372,3 +374,3 @@ return "blocked";

if (reviewStatus === "confirmed")
return closeoutStatus === "closed" ? "not-in-queue" : "confirmed";
return "not-in-queue";
if (budget === "simple" && reviewStatus === "missing")

@@ -386,3 +388,3 @@ return "not-in-queue";

const conflicts = [];
if (state === "done" && closeoutStatus !== "closed") {
if (state === "done" && closeoutStatus !== "closed" && reviewStatus !== "confirmed") {
conflicts.push({ code: "done-without-closeout", severity: "warn", message: "Task state is done, but closeout is still missing or pending." });

@@ -389,0 +391,0 @@ }

@@ -11,2 +11,3 @@ import fs from "node:fs";

import { assessMaterialsReadiness, collectReviewRisks, collectStateConflicts, deriveLifecycleState, deriveReviewQueueState, deriveTaskQueues, isBlockingReviewRisk, parseAgentReviewSubmission, parseReviewConfirmation, parseTaskIdentity, parseTaskTombstone, requiresReviewMaterials, taskReviewStatus, taskScannerVersion, } from "./task-review-model.mjs";
import { attachTaskSemanticProjection } from "./task-semantic-projection.mjs";
import { resolveHarnessPaths, safeAdoptionCapability, taskIdFromDirectory, taskLocalWalkthrough, } from "./harness-paths.mjs";

@@ -280,2 +281,3 @@ import { isExcludedTaskPlanPath } from "./task-discovery-contract.mjs";

walkthroughPath: closeoutInfo.walkthroughPath,
includeWalkthrough: Boolean(reviewSubmission?.submitted || reviewConfirmation?.confirmed || effectiveCloseoutStatus === "closed"),
humanReviewConfirmed: taskAudit.summary.humanReviewStatus === "confirmed",

@@ -320,3 +322,3 @@ })

});
return {
return attachTaskSemanticProjection({
id,

@@ -411,3 +413,3 @@ taskKey: identity.taskKey,

dependencies: [],
};
});
});

@@ -414,0 +416,0 @@ }

@@ -15,3 +15,3 @@ import path from "node:path";

];
if (materials.walkthroughPath) {
if (materials.includeWalkthrough && materials.walkthroughPath) {
files.push({

@@ -18,0 +18,0 @@ label: path.basename(materials.walkthroughPath),

@@ -70,3 +70,2 @@ # Architecture Overview

Registry["coding-agent-harness/harness.yaml<br/>enabled capabilities"]
Docs["docs/"]
Architecture["coding-agent-harness/context/architecture<br/>system facts"]

@@ -79,18 +78,24 @@ Development["coding-agent-harness/context/development<br/>local setup and code map"]

Reference["coding-agent-harness/governance/standards<br/>local operating standards"]
Ledger["Harness Ledger / SSoTs / Lessons<br/>long-lived memory"]
Generated["coding-agent-harness/governance/generated<br/>rebuildable indexes and ledgers"]
Lessons["coding-agent-harness/governance/lessons<br/>promoted lessons"]
LegacyDocs["docs/<br/>legacy migration input only"]
Entry --> Docs
Registry --> Docs
Docs --> Architecture
Docs --> Development
Docs --> QA
Docs --> Integrations
Docs --> Planning
Docs --> Walkthrough
Docs --> Reference
Docs --> Ledger
Entry --> Registry
Registry --> Architecture
Registry --> Development
Registry --> QA
Registry --> Integrations
Registry --> Planning
Registry --> Reference
Planning --> Walkthrough
Planning --> Generated
QA --> Generated
Reference --> Lessons
LegacyDocs -. "migrate-structure input" .-> Registry
```
The target repository is the source of truth. The agent should be able to resume
from these files without relying on previous chat memory.
from `AGENTS.md` and the v2 `coding-agent-harness/` manifest tree without
relying on previous chat memory. A legacy root `docs/` tree may exist during
migration, but active runtime state belongs under `coding-agent-harness/`.

@@ -127,4 +132,5 @@ ## Repository Operating Models

Status --> Scanner["task scanner + check profiles"]
Dashboard --> Bundle["status, tables, docs, graph, adoption warnings"]
Task --> Lifecycle["task lifecycle writer"]
Dashboard --> Bundle["status, semantic projections, tables, docs, graph, adoption warnings"]
Task --> Operations["TaskOperations use cases"]
Operations --> Transaction["HarnessTransaction / scoped writers"]
Migration --> Planner["migration planner and verifier"]

@@ -142,10 +148,12 @@ ```

participant CLI as harness dashboard/dev
participant Scanner as scanner + validators
participant Repo as TaskRepository + scanner
participant Projection as task semantic projection
participant Bundle as dashboard bundle
participant Output as HTML output
participant Browser as browser
participant Target as target docs
participant Target as target harness files
CLI->>Scanner: read AGENTS.md, docs, tasks, SSoTs
Scanner->>Bundle: build status, tables, documents, graph, warnings
CLI->>Repo: read AGENTS.md and coding-agent-harness/
Repo->>Projection: build lifecycle, dashboard, and review queue views
Projection->>Bundle: build status, tables, documents, graph, warnings
Bundle->>Output: write index.html, assets, data/*.json

@@ -155,3 +163,3 @@ Browser->>Output: open static dashboard snapshot

Browser->>CLI: submit approved action
CLI->>Target: update scoped markdown files
CLI->>Target: update scoped Markdown through TaskOperations / transaction
CLI->>Output: regenerate snapshot

@@ -326,3 +334,4 @@ end

Workers own local task and module facts. Coordinators own global projections:
registries, ledgers, closeout indexes, and regression state.
Workers own local task and module facts. Coordinators own global generated views:
module registries, ledgers, closeout indexes, and regression summaries. Those
views are projections, not a second source of truth.

@@ -62,3 +62,2 @@ # 架构总览

Registry["coding-agent-harness/harness.yaml<br/>已启用能力"]
Docs["docs/"]
Architecture["coding-agent-harness/context/architecture<br/>系统事实"]

@@ -71,17 +70,24 @@ Development["coding-agent-harness/context/development<br/>本地设置与代码地图"]

Reference["coding-agent-harness/governance/standards<br/>本地运行标准"]
Ledger["Harness Ledger / SSoT / Lessons<br/>长期记忆"]
Generated["coding-agent-harness/governance/generated<br/>可重建索引与账本"]
Lessons["coding-agent-harness/governance/lessons<br/>已沉淀经验"]
LegacyDocs["docs/<br/>仅作为旧项目迁移输入"]
Entry --> Docs
Registry --> Docs
Docs --> Architecture
Docs --> Development
Docs --> QA
Docs --> Integrations
Docs --> Planning
Docs --> Walkthrough
Docs --> Reference
Docs --> Ledger
Entry --> Registry
Registry --> Architecture
Registry --> Development
Registry --> QA
Registry --> Integrations
Registry --> Planning
Registry --> Reference
Planning --> Walkthrough
Planning --> Generated
QA --> Generated
Reference --> Lessons
LegacyDocs -. "migrate-structure 输入" .-> Registry
```
目标仓库是事实源。Agent 应该能从这些文件恢复上下文,而不是依赖上一轮聊天记忆。
目标仓库是事实源。Agent 应该能从 `AGENTS.md` 和 v2
`coding-agent-harness/` manifest 树恢复上下文,而不是依赖上一轮聊天记忆。
旧根目录 `docs/` 可以在迁移期间存在,但 active runtime 状态属于
`coding-agent-harness/`。

@@ -114,4 +120,5 @@ ## 仓库运行模式

Status --> Scanner["任务扫描器 + check profiles"]
Dashboard --> Bundle["status、tables、docs、graph、adoption warnings"]
Task --> Lifecycle["任务生命周期写入器"]
Dashboard --> Bundle["status、semantic projections、tables、docs、graph、adoption warnings"]
Task --> Operations["TaskOperations 用例"]
Operations --> Transaction["HarnessTransaction / 受限写入器"]
Migration --> Planner["迁移规划器与验证器"]

@@ -128,10 +135,12 @@ ```

participant CLI as harness dashboard/dev
participant Scanner as 扫描器 + 校验器
participant Repo as TaskRepository + scanner
participant Projection as task semantic projection
participant Bundle as dashboard bundle
participant Output as HTML 输出
participant Browser as 浏览器
participant Target as 目标 docs
participant Target as 目标 harness 文件
CLI->>Scanner: 读取 AGENTS.md、docs、tasks、SSoT
Scanner->>Bundle: 构建 status、tables、documents、graph、warnings
CLI->>Repo: 读取 AGENTS.md 和 coding-agent-harness/
Repo->>Projection: 构建 lifecycle、dashboard、review queue 视图
Projection->>Bundle: 构建 status、tables、documents、graph、warnings
Bundle->>Output: 写入 index.html、assets、data/*.json

@@ -141,3 +150,3 @@ Browser->>Output: 打开静态 Dashboard 快照

Browser->>CLI: 提交已批准动作
CLI->>Target: 更新受限 Markdown 文件
CLI->>Target: 通过 TaskOperations / transaction 更新受限 Markdown
CLI->>Output: 重新生成快照

@@ -297,2 +306,2 @@ end

Worker 负责局部任务与模块事实。Coordinator 负责全局投影:registries、ledgers、closeout indexes 和 regression state。
Worker 负责局部任务与模块事实。Coordinator 负责全局生成视图:module registry、ledger、closeout index 和 regression summary。这些视图是 projection,不是第二事实源。

@@ -59,4 +59,4 @@ # 01 — 系统全景

A["📦 Package\n发布的 npm 包\n(CLI + 模板 + 标准 + Preset)"]
B["📁 Target Repo\n用户的项目仓库\n(文档树 + 状态文件)"]
C["⚙️ Runtime\n运行时引擎\n(扫描 + 检查 + 生成)"]
B["📁 Target Repo\n用户的项目仓库\n(Harness 工作区 + 状态文件)"]
C["⚙️ Runtime\n运行时引擎\n(命令注册 + 应用用例 + 投影视图)"]
D["👤 Human\n人类检查入口\n(Dashboard + 审查确认)"]

@@ -71,4 +71,4 @@

- **Package**:你 `npm install` 的那个东西,包含 CLI、模板、标准文档、Preset 包
- **Target Repo**:你的项目,harness 在里面创建 `coding-agent-harness/` 文档树来记录任务状态
- **Runtime**:CLI 运行时,扫描文档树、验证合规、生成 Dashboard
- **Target Repo**:你的项目,harness 在里面创建 `coding-agent-harness/` 工作区来记录任务状态
- **Runtime**:CLI 运行时,通过命令注册、应用用例、仓储端口和语义投影生成可审查视图
- **Human**:浏览器里看 Dashboard,在 Workbench 里做审查确认

@@ -91,3 +91,3 @@

PKG --> CLI["harness CLI\ndist/harness.mjs\n唯一命令入口"]
PKG --> Lib["核心库\nscripts/lib/\n~30 个模块,6 个功能层"]
PKG --> Lib["核心库\nscripts/lib/\n命令注册 + 应用用例 + 领域内核"]
PKG --> Templates["模板\ntemplates/\n任务骨架 + 模块根模板"]

@@ -110,3 +110,3 @@ PKG --> References["操作标准\nreferences/\n可复制到目标仓库的规范文档"]

REPO --> Harness["coding-agent-harness/\nHarness 工作区"]
REPO --> Docs["docs/\nReference 文档和项目标准"]
REPO -.-> LegacyDocs["docs/\n旧结构迁移输入\n不是当前运行态"]

@@ -116,4 +116,5 @@ Harness --> Planning["planning/\n任务目录 + 模块目录"]

Harness --> Walkthrough["任务本地 walkthrough.md\n收口证据"]
Docs --> Reference["coding-agent-harness/governance/standards/\n本地操作标准(从 Package 复制过来)"]
Docs --> Governance["01-GOVERNANCE/\nLesson 沉淀库"]
Harness --> Reference["governance/standards/\n本地操作标准(从 Package 复制过来)"]
Harness --> Governance["governance/lessons/\nLesson 沉淀库"]
LegacyDocs -.-> Migration["legacy migration\n只作为迁移证据读取"]
```

@@ -127,2 +128,5 @@

如果目标仓库还存在根目录 `docs/`,它属于旧结构迁移输入或历史证据,
不是 v2 运行态的 Reference 文档和项目标准来源。
### Runtime 做什么

@@ -134,11 +138,12 @@

RT --> Scan["扫描\n读取所有任务文件\n解析状态 / 阶段 / 审查 / Lesson"]
RT --> Check["检查\n9 个验证器验证合规性\n输出 failures / warnings"]
RT --> Generate["生成\n输出 Dashboard HTML + JSON 索引\n输出治理索引表"]
RT --> Migrate["迁移\n分析旧项目差距\n生成迁移动作队列"]
RT --> Lifecycle["生命周期\n执行状态转换命令\n写入 Governance Sync"]
RT --> Commands["命令注册\nCommandDefinition[] 绑定 CLI 动作"]
RT --> UseCases["应用用例\nTaskOperations 编排生命周期和审查"]
RT --> Ports["端口\nTaskRepository / HarnessTransaction 隔离文件系统写入"]
RT --> Projection["语义投影\nTaskLifecycleProjection / DashboardTaskView"]
RT --> Render["生成\n输出 Dashboard HTML + JSON 索引\n输出治理索引表"]
```
Runtime 是**无状态的**——每次运行都从 Markdown 文件重新读取,
不缓存任何中间状态(除了 `harness dev` 的文件监听)。
Runtime 是**无状态的**——每次运行都从 Markdown 文件重新读取。
Dashboard、Workbench 队列和治理索引都是从任务源文件派生出来的投影视图,
不是第二套事实来源。

@@ -145,0 +150,0 @@ ---

@@ -9,250 +9,194 @@ # 02 — 代码模块依赖关系

flowchart LR
User["用户 / Agent\n$ harness <command> [target]"] -->|"解析参数 + 分发"| Entry["dist/harness.mjs\n唯一 CLI 入口"]
User["用户 / Agent\n$ harness <command> [target]"] --> Entry["dist/harness.mjs\nCLI 入口"]
Entry --> Registry["Command Registry\nCommandDefinition[]"]
Registry --> Handler["命令 handler\ncommands/*.mjs"]
```
`harness.mjs` 做两件事:解析命令行参数,然后分发给对应的 command 模块或直接调用核心库。
它本身不包含任何业务逻辑。
`harness.mjs` 只负责把参数交给 command registry。命令名称、usage、flags、
positionals 和 handler 都注册在 `CommandDefinition` 里;help 输出也从注册表生成。
这让新增命令从“改多个 if/else 和手写 help”收敛成“加一个定义和一个 handler”。
---
## Level 1 — 命令如何分发
## Level 1 — 当前分层
底层重构后的公开架构不是“六个平级功能模块”,而是一个 facade-first 的
ports/adapters 结构。旧 scanner、governance-sync、preset runner 仍然存在,但它们被
当作 infrastructure / legacy adapter 接入,而不是让 CLI 或 Dashboard 直接绕过业务层。
```mermaid
flowchart TD
Entry["dist/harness.mjs"]
flowchart TB
Adapter["Adapters\nCLI / Dashboard Workbench / Preset / Migration"]
App["Application use cases\nTaskOperations / module governance / workbench actions"]
Domain["Domain kernel\nstate policy / review gates / module ownership / preset model"]
Ports["Ports\nTaskRepository / HarnessTransaction / projections"]
Infra["Infrastructure + legacy adapters\nscanner / markdown / fs / git / governance-sync / preset runner"]
Entry -->|"dashboard\ndev"| DashCmd["dist/commands/dashboard-command.mjs\nDashboard 生成 + 动态服务"]
Entry -->|"migrate-plan\nmigrate-run\nmigrate-verify"| MigCmd["dist/commands/migration-command.mjs\n迁移三阶段命令"]
Entry -->|"new-task / task-start\ntask-phase / task-review\ntask-complete / task-tombstone"| TaskCmd["dist/commands/task-command.mjs\n任务生命周期命令"]
Entry -->|"preset catalog\npreset install\npreset uninstall"| PresetCmd["dist/commands/preset-command.mjs\nPreset 管理命令"]
Entry -->|"check / status / init\ngovernance / lesson-promote\n..."| Core["dist/lib/harness-core.mjs\n(直接调用)"]
Adapter --> App
App --> Domain
App --> Ports
Ports --> Infra
Infra --> Ports
```
四个 command 模块各自负责一个领域,其余命令直接调用 `harness-core.mjs`。
依赖方向是向内的:adapter 负责协议和呈现,application 负责业务动作,domain
负责规则,ports 定义边界,infrastructure 实现文件系统、Git、Markdown 和遗留扫描能力。
**为什么这样分**:command 模块处理的是有复杂交互逻辑的命令(多步骤、需要读写多个文件、
有用户提示),而简单的查询类命令(`check`、`status`)直接调用核心库更简洁。
---
## Level 2 — harness-core.mjs 是什么
## Level 2 — 命令注册表
`harness-core.mjs` 是一个 **facade(门面)**,它自己不写任何业务逻辑,
只是把 `lib/` 下所有模块的导出重新 re-export 出来。
这样设计的好处:外部代码只需要 `import from "./lib/harness-core.mjs"` 就能拿到所有功能,
不需要知道具体在哪个子模块里。
```mermaid
flowchart TD
Core["harness-core.mjs\n(纯 re-export facade)"]
Core --> G1["① 核心工具层\ncore-shared + markdown-utils"]
Core --> G2["② 任务扫描层\ntask-scanner + review-model + lesson-candidates"]
Core --> G3["③ 检查与治理层\ncheck-profiles + governance-sync + governance-index"]
Core --> G4["④ Dashboard 层\ndashboard-data + dashboard-writer + workbench"]
Core --> G5["⑤ 任务生命周期层\ntask-lifecycle + review-gates + review-confirm"]
Core --> G6["⑥ 迁移与 Preset 层\nmigration-planner + preset-registry + tombstone"]
flowchart LR
Harness["scripts/harness.mts"] --> Dispatch["dispatchCommand()"]
Dispatch --> Defs["CommandDefinition[]"]
Defs --> Dash["dashboard / dev"]
Defs --> Migration["migrate-*"]
Defs --> Task["new-task / task-*"]
Defs --> Preset["preset *"]
Defs --> Module["module *"]
Defs --> Core["check / status / init / governance"]
```
下面逐层展开。
`commands/registry.mts` 是命令面的 source of truth。它声明每个命令的名称、usage、
flag schema、positionals 和 handler。复杂命令仍分发到 `dashboard-command.mts`、
`migration-command.mts`、`task-command.mts`、`preset-command.mts` 和
`module-command.mts`,但注册和 help 不再散落在入口文件里。
---
## Level 3 — 六个功能层详解
## Level 2 — 任务读路径:TaskRepository
### ① 核心工具层
```mermaid
flowchart TD
Consumer["status / dashboard / workbench / lifecycle / task-index"]
Repo["TaskRepository\nlist / get / resolve / readMaterials"]
Scanner["legacy scanner\ncollectTasks + task discovery"]
Files["Markdown task package\nINDEX / task_plan / progress / review / visual_map"]
这两个模块是所有其他模块的基础,几乎每个模块都会 import 它们:
```mermaid
flowchart LR
CoreShared["core-shared.mjs\n路径解析 / 常量枚举\n文件读写 / locale 处理\n模板渲染"]
MarkdownUtils["markdown-utils.mjs\nMarkdown 表格提取\n行更新 / 列查找\n依赖列表拆分"]
Consumer --> Repo
Repo --> Scanner
Scanner --> Files
```
`core-shared` 定义了所有允许的枚举值,是整个系统的"类型系统":
`TaskRepository` 是读缝。第一版实现仍包装现有 scanner;重点不是重写 scanner,
而是让调用方停止直接依赖 task discovery 细节。它提供四类能力:
| 枚举 | 允许值 |
| 方法 | 用途 |
| --- | --- |
| `allowedTaskStates` | `not_started / planned / in_progress / review / blocked / done` |
| `allowedTaskBudgets` | `simple / standard / complex` |
| `allowedPhaseStates` | `planned / in_progress / review / blocked / done / skipped` |
| `allowedCapabilities` | `core / module-parallel / subagent-worker / adversarial-review / ...` |
| `list(query)` | 列出任务并应用 state、module、queue、preset、review、lesson 等查询条件 |
| `get(ref)` | 用 id 或路径取一个任务 |
| `resolve(ref)` | 把任务引用解析成目录和 `task_plan.md` 路径 |
| `readMaterials(ref)` | 读取任务包中可审查的 Markdown 文件 |
`markdown-utils` 提供了对 Markdown 表格的结构化操作——这是整个系统能从 Markdown 文件
派生状态的技术基础。
这个 facade 让以后替换 scanner 内部实现时,不需要逐个改 Dashboard、Workbench、
check 和 lifecycle 调用方。
---
### ② 任务扫描层
## Level 2 — 任务写路径:TaskOperations
负责读取 `coding-agent-harness/planning/tasks/` 下的所有文件,解析出结构化数据:
```mermaid
flowchart TD
TaskScanner["task-scanner.mjs\n扫描所有任务目录\n解析状态 / 预算 / 阶段 / 元数据"]
CLI["CLI task commands"]
Workbench["Dashboard Workbench"]
Ops["TaskOperations\ncreate / start / review / complete / confirmReview / delete / archive / supersede / reopen / lessonSediment"]
Repo["TaskRepository"]
LegacyWrite["legacy lifecycle / tombstone / lesson writers"]
TaskScanner --> ReviewModel["task-review-model.mjs\n审查确认解析\n生命周期队列派生\ntombstone 解析"]
TaskScanner --> LessonCandidates["task-lesson-candidates.mjs\nLesson candidate 状态解析\n决策完成判定"]
ReviewModel --> CoreShared
ReviewModel --> MarkdownUtils
TaskScanner --> CoreShared
TaskScanner --> MarkdownUtils
CLI --> Ops
Workbench --> Ops
Ops --> Repo
Ops --> LegacyWrite
```
`task-review-model` 里有几个关键的**派生函数**——它们不读文件,
只根据已解析的数据计算出新的状态:
`TaskOperations` 是 application use-case 层。CLI 和 Dashboard Workbench 都通过它做任务动作,
所以“能不能确认 review”“能不能 task-complete”“open blocking findings 是否阻断”等规则不会在多个入口分叉。
| 函数 | 输入 | 输出 |
| --- | --- | --- |
| `deriveLifecycleState()` | taskState + reviewStatus + tombstone | `lifecycleState`(队列分类) |
| `deriveTaskQueues()` | lifecycleState + materials + lessons | `taskQueues[]`(属于哪些队列) |
| `deriveReviewQueueState()` | findings + confirmation | `reviewQueueState` |
| `parseTaskTombstone()` | task_plan.md 内容 | 软删除 / 合并 / 被取代状态 |
当前实现仍会调用 legacy lifecycle writer 来实际更新 Markdown;这是有意的过渡形态。
架构边界先收口到 use case,再逐步把底层 writer 迁入统一 transaction。
这些派生函数是**纯函数**,相同输入永远得到相同输出,便于测试和调试。
---
### ③ 检查与治理层
## Level 2 — 写入事务:HarnessTransaction
负责验证合规性,以及维护全局索引的原子写入:
```mermaid
flowchart TD
CheckProfiles["check-profiles.mjs\nbuildStatus() 编排 9 个验证器\n返回 failures + warnings + tasks"]
flowchart LR
UseCase["write use case / preset action / governance rebuild"]
ChangeSet["ChangeSet\nwrites / deletes / generated surfaces / commit policy"]
Tx["HarnessTransaction\nplan + apply"]
GovSync["governance-sync legacy adapter\nlock + allowed paths + git commit"]
Git["Git history"]
CheckProfiles --> V1["validateCapabilities\n能力注册表一致性"]
CheckProfiles --> V2["validateReviewSchema\nreview.md 结构"]
CheckProfiles --> V3["validateVisualMaps\nvisual_map 合规"]
CheckProfiles --> V4["validatePlanContracts\n任务合约标记"]
CheckProfiles --> V5["validateTaskPresetContracts\nPreset 合约"]
CheckProfiles --> V6["validateContextDocs\n上下文文档完整性"]
CheckProfiles --> V7["validateGovernanceTableBoundaries\n表格边界"]
CheckProfiles --> V8["validateSubagentAuthorization\nsubagent 授权"]
CheckProfiles --> V9["validateTaskCompletionConsistency\n完成一致性"]
CheckProfiles --> GitSummary["git-status-summary.mjs\nGit 状态摘要(dirty files 等)"]
GovSync["governance-sync.mjs\n原子锁 + 行级更新 + Git commit\n(任务状态变更时自动调用)"]
GovIndex["governance-index-generator.mjs\n重建全局索引表\n(手动触发)"]
GovIndex --> GovSync
UseCase --> ChangeSet
ChangeSet --> Tx
Tx --> GovSync
GovSync --> Git
```
**重要区分**:`governance-sync` 和 `check-profiles` 没有依赖关系。
- `check-profiles`:只读,验证状态,不写文件
- `governance-sync`:只写,更新账本,不做验证
`HarnessTransaction` 把写入计划、allowed paths、generated surfaces、dirty tree 检查、
dry-run 语义和 Git commit 结果收进一个命名边界。它的核心类型是:
---
| 类型 | 作用 |
| --- | --- |
| `ChangeSet` | 声明本次操作要写、删、生成什么,以及 commit 策略 |
| `TransactionPlan` | 规范化后的计划,包含 allowed paths、generated surfaces、Git 状态和冲突 |
| `TransactionResult` | apply 后的成功/失败、写入列表、提交结果和 lock release 状态 |
### ④ Dashboard 层
这不是新的事实源。事务只管理写入安全和提交边界;任务事实仍然来自
`coding-agent-harness/` 下的 Markdown 文件。
负责把扫描结果转换成 HTML Dashboard:
```mermaid
flowchart TD
DashData["dashboard-data.mjs\nbuildDashboardBundle()\n收集 status + documents + tables + graph + adoption"]
DashData --> CheckProfiles["check-profiles.mjs\n(调用 buildStatus)"]
DashData --> DashWriter["dashboard-writer.mjs\n写入 HTML + JSON 文件\n(静态快照模式)"]
DashData --> StatusRenderer["status-dashboard-renderer.mjs\n渲染状态摘要文本"]
DashWorkbench["dashboard-workbench.mjs\nDev 动态服务\nHTTP server + 文件监听 + 自动刷新\n(harness dev 命令)"]
```
`DashWorkbench` 和 `DashData` / `DashWriter` 是**独立的**:
- `DashData` + `DashWriter`:生成静态快照(只读)
- `DashWorkbench`:启动本地 HTTP 服务,支持 Workbench 写操作
---
### ⑤ 任务生命周期层
## Level 2 — 语义投影:Task Semantic Projection
负责执行所有任务状态转换命令:
```mermaid
flowchart TD
TaskLifecycle["task-lifecycle.mjs\n生命周期命令实现\nnew-task / task-start / task-phase\ntask-review / task-complete"]
Raw["raw task record\nstate / reviewStatus / taskQueues / closeoutStatus / lessons"]
Projection["Task semantic projection"]
Lifecycle["TaskLifecycleProjection\ncanonical task lifecycle fields"]
Dashboard["DashboardTaskView\nswimlane stage + display affordances"]
Review["ReviewWorkbenchQueueView\nqueue / confirmability / blocking reasons"]
TaskLifecycle --> ReviewGates["task-lifecycle/review-gates.mjs\n门禁验证逻辑\n(进入 review 前的检查)"]
TaskLifecycle --> ReviewConfirm["task-lifecycle/review-confirm.mjs\n人工确认执行\n(Workbench 内部写入口)"]
TaskLifecycle --> TextUtils["task-lifecycle/text-utils.mjs\n文本追加工具\n(向 Markdown 文件追加内容)"]
TaskLifecycle --> GovSync["governance-sync.mjs\n状态变更时同步账本"]
TaskLifecycle --> MigPreset["task-migration-preset.mjs\n迁移 preset 上下文注入"]
ReviewConfirm --> GitGate["review-confirm-git-gate.mjs\nGit 原子提交门禁\n(写入人工确认块 + commit)"]
Raw --> Projection
Projection --> Lifecycle
Projection --> Dashboard
Projection --> Review
```
人工确认是整个生命周期层里最特殊的写操作——它只通过本地 Workbench 触发,并需要 Git 原子提交
(见 [01-system-overview.md](01-system-overview.md) 的设计决策)。
Task semantic projection 解决的是“同一个 review 概念在 status、Dashboard、Workbench、
generated indexes 中被多次解释”的问题。它把 raw task record 包成三个 view model:
---
| Projection | 消费者 | 不能做什么 |
| --- | --- | --- |
| `TaskLifecycleProjection` | status JSON、task-index、Dashboard、governance rows | 不能写回 source 文件 |
| `DashboardTaskView` | task list、detail drawer、swimlane | 不能在前端重新发明 lifecycle 规则 |
| `ReviewWorkbenchQueueView` | review workbench、批量确认动作、review queue 视图 | 不能从 Markdown 重新计算 blocking risks |
### ⑥ 迁移与 Preset 层
Projection 可以落盘或缓存为 generated JSON,但它不是权威事实源;删掉后必须能从任务文件重建。
```mermaid
flowchart TD
PresetReg["preset-registry.mjs\n读取 presets/ YAML\n验证包完整性\n分层发现(project / user / bundled)"]
PresetEngine["preset-engine.mjs\n执行 preset entrypoints\n(template / script / check 类型)"]
PresetAudit["preset-audit-contracts.mjs\n验证 preset 合约完整性"]
PresetResource["preset-resource-contracts.mjs\n验证 preset 资源声明"]
MigPlanner["migration-planner.mjs\n分析目标仓库差距\n生成迁移动作队列"]
MigSupport["migration-support.mjs\nsession 管理 / locale 探测\nGit 状态检查 / full-cutover 验证"]
Tombstone["task-tombstone-commands.mjs\n软删除 / 合并 / 重开命令"]
LessonSed["task-lesson-sedimentation.mjs\nLesson 沉淀任务创建"]
LessonMaint["lesson-maintenance.mjs\nLesson 库维护"]
TaskIndex["task-index.mjs\n任务索引生成"]
MigPlanner --> MigSupport
PresetEngine --> PresetReg
```
---
## 一张完整的依赖总图(参考用)
## Level 3 — Legacy 模块仍然在哪里
如果你已经理解了上面的分层,这张图可以作为查阅索引:
很多文件名仍保留在 `scripts/lib/` 下,这是为了让重构可验证、可回滚,而不是一次性搬目录。
它们的当前角色如下:
```mermaid
flowchart TD
Entry["harness.mjs"] --> DashCmd & MigCmd & TaskCmd & PresetCmd & Core["harness-core.mjs"]
| 模块 | 当前角色 |
| --- | --- |
| `task-scanner.mts` / `task-review-model.mts` | TaskRepository 背后的 legacy read implementation |
| `governance-sync.mts` | HarnessTransaction 背后的 legacy write / commit adapter |
| `task-lifecycle.mts` | 仍执行部分 Markdown 写入,逐步由 TaskOperations 和 transaction 收口 |
| `dashboard-data.mts` / `dashboard-workbench.mts` | Dashboard adapter 和 projection consumer |
| `preset-runner.mts` | Preset adapter,逐步收敛到 ChangeSet / transaction 模式 |
Core --> CoreShared & MarkdownUtils
Core --> TaskScanner --> ReviewModel & LessonCandidates
Core --> CheckProfiles --> GitSummary
Core --> GovSync
Core --> GovIndex --> GovSync
Core --> DashData --> DashWriter & StatusRenderer
Core --> DashWorkbench
Core --> TaskLifecycle --> ReviewGates & ReviewConfirm & TextUtils & GovSync & MigPreset
ReviewConfirm --> GitGate
Core --> PresetReg
Core --> PresetEngine --> PresetReg
Core --> MigPlanner --> MigSupport
Core --> Tombstone
Core --> LessonSed
Core --> LessonMaint
Core --> TaskIndex
```
阅读代码时,先找 adapter 调哪个 use case,再看 use case 依赖哪个 port。只有 port 的 legacy
实现内部才应该直接接触 scanner、Markdown 文件细节或 governance-sync。
---
## Level 2 — 模块命名规律
## 下一步
理解命名规律可以帮你快速定位代码:
| 前缀 / 后缀 | 含义 | 例子 |
| --- | --- | --- |
| `task-` | 与任务相关 | `task-scanner`, `task-lifecycle`, `task-review-model` |
| `dashboard-` | 与 Dashboard 相关 | `dashboard-data`, `dashboard-writer`, `dashboard-workbench` |
| `governance-` | 与治理 / 账本相关 | `governance-sync`, `governance-index-generator` |
| `migration-` | 与迁移相关 | `migration-planner`, `migration-support` |
| `preset-` | 与 Preset 相关 | `preset-registry`, `preset-engine`, `preset-audit-contracts` |
| `check-` | 验证器 | `check-profiles`, `check-module-parallel` |
| `-command.mjs` | CLI 命令模块 | `task-command`, `dashboard-command` |
| `-utils.mjs` | 工具函数 | `markdown-utils`, `text-utils` |
| `-gates.mjs` | 门禁逻辑 | `review-gates`, `review-confirm-git-gate` |
- 想理解一个任务怎么走:读 [03-task-lifecycle.md](03-task-lifecycle.md)
- 想理解检查器和治理:读 [04-check-and-governance.md](04-check-and-governance.md)
- 想理解 Dashboard 数据流和 projection:读 [05-data-flow.md](05-data-flow.md)

@@ -5,3 +5,3 @@ # 03 — 任务生命周期

一个任务从创建到收口,经历六个状态:
一个任务从创建到最终结项,包含执行状态、人工确认点和阻塞/返工路径。注意这里的最终结项不同于 Agent 在提交人工审查前准备的 closeout packet:

@@ -12,3 +12,4 @@ ```mermaid

C --> D["review\n等待人工审查"]
D --> E["done\n收口完成"]
D --> H["human_confirmed\n人工已确认"]
H --> E["done\n最终结项完成"]
C -->|"外部阻塞"| F["blocked"] -->|"解除"| C

@@ -30,3 +31,4 @@ D -->|"打回重做"| C

R["review\n等待人工审查"]
D["done\n收口完成"]
HC["human_confirmed\n人工已确认"]
D["done\n最终结项完成"]
BL["blocked\n外部依赖阻塞"]

@@ -36,3 +38,4 @@

IP -->|"harness task-review"| R
R -->|"Dashboard workbench\n人工确认"| D
R -->|"Dashboard workbench\n人工确认"| HC
HC -->|"harness task-complete"| D
IP -->|"harness task-phase --blocked"| BL

@@ -44,3 +47,3 @@ BL -->|"harness task-start"| IP

**关键点**:人工确认不作为普通 CLI 命令暴露。它只能通过本地 Dashboard workbench 的写接口触发,
并会写入带有 Git `user.name` / `user.email` 的可审计确认块。
并会写入带有 Git `user.name` / `user.email` 的可审计确认块。Agent 的完成边界是在人工审查前准备好 evidence、`review.md`、`walkthrough.md`、lesson decision 和 residual risk;人工确认之后的 `task-complete` 只是最终生命周期结项。

@@ -47,0 +50,0 @@ ---

@@ -7,11 +7,17 @@ # 05 — 数据流:从 Markdown 到 Dashboard

flowchart LR
A["📄 Markdown 文件\n(coding-agent-harness/ 下的源文件)"]
B["⚙️ Scanner\n(解析 + 验证)"]
C["📊 Dashboard\n(HTML + JSON)"]
Source["Markdown source of truth\ncoding-agent-harness/"]
Repository["TaskRepository\n兼容读缝"]
Status["status model\nscanner + validators"]
Projection["Task semantic projection\n统一生命周期 / 审查 / 队列语义"]
Dashboard["Dashboard / Workbench / generated indexes"]
A -->|"读取"| B -->|"生成"| C
Source --> Repository
Repository --> Status
Status --> Projection
Projection --> Dashboard
```
所有数据都来自 Markdown 文件,没有数据库,没有外部服务。
每次运行都从文件系统重新读取,不缓存任何中间状态。
权威事实只在 `coding-agent-harness/` 下的 Markdown 文件里。Scanner、status JSON、
Dashboard bundle、generated indexes 和 projection 都是可再生视图;它们可以缓存或落盘,
但不能成为第二事实源。

@@ -26,154 +32,134 @@ ---

Sources --> TP["task_plan.md\n预算 / 标题 / 元数据 / Preset 信息\nTask Contract 标记"]
Sources --> IDX["INDEX.md\nTask Audit Metadata / review confirmation"]
Sources --> TP["task_plan.md\n预算 / 标题 / Tombstone / Preset 信息 / Task Contract"]
Sources --> PR["progress.md\n当前状态 / 操作日志"]
Sources --> VM["visual_map.md\n阶段列表 / 完成度 / 证据状态"]
Sources --> RV["review.md\nfindings 表格 / 人工确认块"]
Sources --> RV["review.md\nAgent review submission / findings"]
Sources --> BR["brief.md\n任务摘要"]
Sources --> LC["lesson_candidates.md\nLesson 候选 / 决策状态"]
Sources --> HL["Harness-Ledger.md\n全局账本(所有任务汇总行)"]
Sources --> ES["execution_strategy.md\nsubagent 授权状态"]
Sources --> WO["walkthrough.md\n收口证据"]
```
生成的 `Harness-Ledger.md`、task-index、module-index、Closeout index 和 Dashboard JSON
不是手写 source。它们用于浏览、审查和恢复上下文,但必须能从 source 文件重建。
---
## Level 2 — Scanner 如何处理这些文件
## Level 2 — TaskRepository 如何读取任务
Scanner 层(`task-scanner.mjs` + `task-review-model.mjs`)把原始 Markdown 解析成结构化对象。
### collectTasks() 的发现流程
```mermaid
flowchart TD
CT["collectTasks()"]
Repo["TaskRepository"]
List["list(query)"]
Get["get(ref)"]
Resolve["resolve(ref)"]
Materials["readMaterials(ref)"]
Scanner["legacy scanner\ncollectTasks / task discovery"]
CT --> Discover["listTaskPlanPaths()\n扫描两个根目录:\ncoding-agent-harness/planning/tasks/\ncoding-agent-harness/planning/modules/\n过滤模板和归档目录"]
Repo --> List
Repo --> Get
Repo --> Resolve
Repo --> Materials
List --> Scanner
Get --> Scanner
Resolve --> Scanner
Materials --> Scanner
```
Discover --> ReadFiles["对每个任务目录读取 9 个文件\ntask_plan / brief / progress\nreview / visual_map\nexecution_strategy\nlesson_candidates / findings / context"]
`TaskRepository` 包住现有 scanner。调用方看到的是任务记录和任务材料,而不是
`listTaskPlanPaths()`、目录排除规则、legacy visual map fallback 等内部细节。
ReadFiles --> Parse["解析各文件"]
### 任务发现流程
Parse --> P1["parseTaskBudget()\n从 task_plan.md 提取 budget"]
Parse --> P2["parseTaskState()\n从 progress.md 提取 state"]
Parse --> P3["parsePhases()\n从 visual_map.md 提取阶段列表"]
Parse --> P4["parseAgentReviewSubmission()\n从 review.md 提取 Agent 提交状态"]
Parse --> P5["parseReviewConfirmation()\n从 review.md 提取人工确认块"]
Parse --> P6["parseLessonCandidateStatus()\n从 lesson_candidates.md 提取决策状态"]
Parse --> P7["parseTaskTombstone()\n从 task_plan.md 提取软删除状态"]
P1 & P2 & P3 & P4 & P5 & P6 & P7 --> Derive["派生计算(纯函数)"]
Derive --> LS["deriveLifecycleState()\n综合推导 lifecycleState"]
Derive --> QS["deriveTaskQueues()\n决定任务属于哪些队列"]
Derive --> RQS["deriveReviewQueueState()\n推导 reviewQueueState"]
```mermaid
flowchart TD
CT["collectTasks()"]
CT --> Discover["扫描\ncoding-agent-harness/planning/tasks/\ncoding-agent-harness/planning/modules/<key>/tasks/"]
Discover --> Read["读取任务包 Markdown\nINDEX / task_plan / brief / progress / review / visual_map / lesson_candidates / findings / walkthrough"]
Read --> Parse["解析 raw fields"]
Parse --> Status["组装 status task record"]
```
### parseTaskState() 的格式
scanner 仍负责解析 Markdown 表格、状态、阶段、review submission、confirmation、
lesson decision 和 tombstone。重构后的差别是:这些 raw fields 不应被每个 UI 或命令面重复解释。
从 `progress.md` 的 `## Current Status` 或 `## Status` 标题后的第一行提取状态:
---
```markdown
## Current Status
## Level 2 — status 输出是什么
in_progress
```
`buildStatus()` 从 repository/scanner 结果和验证器结果组装机器可读状态:
支持中文别名(`进行中` → `in_progress`)。如果格式不符合预期,会降级到遗留表格解析模式。
### parsePhases() 的表格格式
从 `visual_map.md` 中查找 `Phase ID` 列标题的表格,提取 9 个字段:
```markdown
| Phase ID | Depends On | State | Completion | Output | Required Evidence | Evidence Status | Blocking Risk | Owner / Handoff |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| P1 | — | done | 100 | ... | E-001 | present | low | coordinator |
```mermaid
flowchart TD
Status["buildStatus()"]
Status --> Tasks["tasks[]\nraw task records + semantic projection"]
Status --> Failures["failures[]"]
Status --> Warnings["warnings[]"]
Status --> Caps["capabilities[]"]
Status --> Git["git status summary"]
Status --> Summary["summary metrics"]
```
依赖字段支持逗号、分号、`&` 分隔的多值。
每个 task record 仍保留 raw 字段,例如 `state`、`reviewStatus`、`reviewQueueState`、
`taskQueues`、`closeoutStatus`、`materialsReady` 和 `lessonCandidateDecisionComplete`。
这些字段便于调试,但 Dashboard 和 generated governance rows 应优先消费 semantic projection。
---
## Level 2 — buildStatus() 把 Scanner 结果组装成什么
## Level 2 — Task Semantic Projection
```mermaid
flowchart TD
BS["buildStatus()"]
Raw["raw task record"]
Projection["buildTaskSemanticProjection()"]
Lifecycle["TaskLifecycleProjection"]
DashView["DashboardTaskView"]
ReviewView["ReviewWorkbenchQueueView"]
BS --> Tasks["tasks[]\n每个任务的完整结构化数据\n(见下方字段列表)"]
BS --> Failures["failures[]\n硬失败列表"]
BS --> Warnings["warnings[]\n软警告列表"]
BS --> Caps["capabilities[]\n能力注册表状态"]
BS --> Git["git\nGit 状态摘要(dirty files 等)"]
BS --> Summary["summary\nbriefCoverage / visualMapCoverage\nfullCutoverEligible 等汇总指标"]
Raw --> Projection
Projection --> Lifecycle
Projection --> DashView
Projection --> ReviewView
```
**task 对象的完整字段**:
Projection 将一个 raw task record 包成三个明确视图:
| 字段 | 含义 |
| --- | --- |
| `path` | 相对路径 |
| `title` | 任务标题 |
| `state` | 任务状态(`in_progress / done / ...`) |
| `stateSource` | 状态来源(`valid / invalid`) |
| `budget` | 预算等级(`simple / standard / complex`) |
| `lifecycleState` | 派生的生命周期状态 |
| `reviewQueueState` | 审查队列状态 |
| `taskQueues[]` | 所属队列列表 |
| `phases[]` | 阶段列表(含 id / state / completion / evidenceStatus) |
| `closeoutStatus` | 收口状态 |
| `tombstone` | 软删除信息 |
| `briefSource` | brief 来源(`standalone / missing / ...`) |
| `visualMapSource` | visual map 来源(`canonical / legacy / missing`) |
| `taskPreset` | 使用的 Preset ID |
| `presetVersion` | Preset 版本 |
| `handoffs` | 交接信息数组 |
| `lessonCandidateDecisionComplete` | Lesson 决策是否完成 |
| Projection | 核心字段 | 消费者 |
| --- | --- | --- |
| `TaskLifecycleProjection` | `state`, `lifecycleState`, `reviewStatus`, `reviewQueueState`, `closeoutStatus`, `taskQueues`, `materialsReady`, `reviewSubmitted`, `deletionState` | status JSON、task-index、generated governance rows |
| `DashboardTaskView` | `visibleInSwimlane`, `swimlaneStage`, `needsEvidence`, `reasonCode`, `reasonMessage` | Dashboard task list、detail drawer、swimlane |
| `ReviewWorkbenchQueueView` | `primaryQueue`, `humanConfirmable`, `blocked`, `needsMaterials`, `confirmed`, `finalized`, `readyForCloseout`, `reasonCodes` | Review Workbench、批量确认、review queue |
这个边界防止同一任务在顶部统计、生命周期工作台、泳道图、review table 里出现不同口径。
前端可以决定布局、颜色和过滤,但不能重新定义一个任务是否 review-ready、blocked、
confirmed 或 finalized。
---
## Level 3 — buildDashboardBundle() 在 status 基础上再加什么
## Level 3 — Dashboard bundle 如何使用 projection
`buildDashboardBundle()` 调用 `buildStatus()` 后,再额外收集四类数据:
```mermaid
flowchart TD
Bundle["buildDashboardBundle()"]
Bundle --> Status["status\n含 semanticProjection"]
Bundle --> Documents["documents[]\nMarkdown 文档内容"]
Bundle --> Tables["tables[]\nMarkdown 表格结构"]
Bundle --> Graph["graph\n任务 / 阶段 / 模块依赖"]
Bundle --> Adoption["adoption\n迁移采纳状态"]
Bundle --> Status["status\n(来自 buildStatus)"]
Bundle --> Documents["documents[]\n所有 Markdown 文档的内容\n每个文档:id / path / title / type / content"]
Bundle --> Tables["tables[]\n从文档中提取的 Markdown 表格\n每个表格:列定义 + 行数据"]
Bundle --> Graph["graph\n任务依赖关系图\nnodes(任务/阶段/模块/步骤)\nedges(依赖/包含/交接关系)"]
Bundle --> Adoption["adoption\n迁移采纳状态分析\n(warnings 分类汇总,含优先级和修复建议)"]
Status --> UI["Dashboard UI"]
Status --> Workbench["Workbench API responses"]
Status --> Generated["generated task rows"]
```
Dashboard bundle 在 status 基础上收集文档、表格、图和 adoption 分析。任务生命周期、
review queue、swimlane stage 和 confirmability 应从 projection 读取,而不是在
`app.js` 或 Workbench handler 里重新混合 raw `state`、`reviewStatus`、`taskQueues`。
### documents 收集范围
`collectMarkdownDocuments()` 收集哪些文件:
`collectMarkdownDocuments()` 仍会收集固定治理文件、任务包文件、模块文件和 lesson 文件。
这些文档用于人类阅读和表格浏览,不改变任务生命周期语义。
```mermaid
flowchart TD
Collect["collectMarkdownDocuments()"]
Collect --> Fixed["固定路径(存在时收集)\nHarness-Ledger.md\nharness.yaml modules.items\ncoding-agent-harness/planning/modules/Module-Registry.md\ncoding-agent-harness/governance/regression/Regression-SSoT.md\ncoding-agent-harness/governance/generated/Closeout-Index.md"]
Collect --> Walkthrough["coding-agent-harness/planning/tasks/<task>/ 下所有 .md\n(排除 _archive/ 和 _ 开头文件)"]
Collect --> TaskDocs["每个任务目录下:\nbrief / task_plan / execution_strategy\nvisual_map / lesson_candidates\nprogress / review / findings\nreferences/INDEX.md / artifacts/INDEX.md"]
Collect --> ModuleDocs["coding-agent-harness/planning/modules/ 下:\n每个模块根 brief.md / module_plan.md\n每个模块 tasks/<task>/ 的任务合同"]
Collect --> Lessons["01-GOVERNANCE/lessons/ 下所有 .md"]
```
收集后统一过滤掉 `_archive/`、`_task-template/`、`_optional-structures/` 路径。
### graph 数据结构
图包含两类元素:
- **nodes**:任务节点、阶段节点、模块节点、步骤节点
- **edges**:依赖关系(阶段 → 阶段)、包含关系(任务 → 阶段)、交接关系(步骤 → 步骤)
如果依赖的源节点不存在,会创建一个 `external-dependency` 类型的虚拟节点。
---

@@ -187,4 +173,4 @@

SC["harness dashboard\n--out-dir ./out"]
SC --> SH["index.html\n(内联所有资源)"]
SC --> SJ["dashboard-data.json\n(供外部工具读取)"]
SC --> SH["index.html\n内联资源"]
SC --> SJ["dashboard-data.json"]
SC --> SF["status.json / tables.json\ndocuments.json / graph.json\nadoption.json"]

@@ -195,45 +181,20 @@ end

DC["harness dev"]
DC --> HTTP["本地 HTTP 服务\nlocalhost:PORT"]
DC --> Watch["文件监听\n轮询模式,每 1 秒检查一次\n变化后延迟 250ms 重新生成"]
HTTP --> Browser["浏览器实时查看\n支持人工确认等写操作"]
DC --> HTTP["本地 HTTP 服务\n127.0.0.1"]
HTTP --> Browser["实时查看\n人工确认等写操作"]
Browser --> Ops["TaskOperations\n业务动作"]
Ops --> Tx["HarnessTransaction / legacy writers\n受限 Markdown 写入 + Git commit"]
end
```
**关键边界**:静态 Dashboard 是只读的,不能触发任何写操作。
只有 `harness dev`(Workbench 模式)才能执行人工确认等写操作。
静态 Dashboard 是可分享的证据快照,不能触发写操作。Workbench 只能本地使用;
写操作必须经过 host/origin/CSRF 校验、TaskOperations 业务门禁和受限写入边界。
### Dashboard HTML 生成方式
Dashboard HTML 通过字符串拼接生成(非模板引擎)。
`app.js` 通过 manifest 或直接读取获得——如果存在 manifest,
按顺序读取并拼接多个源文件(`app-src/` 目录下的模块化源码)。
payload 中的 `<` 被转义为 `<` 防止 HTML 注入。
### 文件监听实现
`dashboard-workbench.mjs` 的文件监听采用**轮询模式**(`startPollingWatch()`):
- 每 1000ms 检查一次目录树的最新修改时间(mtime)
- 检测到变化时,延迟 250ms 后触发重新生成(防抖)
- 监听范围:整个 `target.docsRoot`,排除 `.git`、`node_modules`、`tmp`
---
## Level 3 — markdown-utils.mjs 的核心能力
## Level 3 — markdown-utils.mjs 的角色
整个系统能从 Markdown 文件派生状态,技术基础是 `markdown-utils.mjs` 提供的表格解析能力:
`markdown-utils.mjs` 仍是 Markdown 表格解析的技术基础。它负责表格行提取、列定位、
单元格读取、列表拆分和 dependency 拆分。它只提供低层解析能力,不决定任务是否可确认、
是否完成、是否属于 review 队列。
| 函数 | 用途 |
| --- | --- |
| `markdownTableRows()` | 提取所有表格行 |
| `parseAllMarkdownTables()` | 解析文档中所有表格,返回结构化对象数组 |
| `splitMarkdownRow()` | 分割行单元格(处理转义管道符和代码块) |
| `tableAfterHeading()` | 定位特定标题后的表格 |
| `getCell()` | 按列名获取单元格(支持多个别名) |
| `splitList()` | 分割逗号/分号/加号分隔的列表 |
| `splitDependencies()` | 分割依赖,过滤 `none/n/a` 等占位符 |
**代码块内的管道符**:`splitMarkdownRow()` 会跟踪代码块状态,
代码块内的 `|` 不会被当作列分隔符,保留原始内容。
---

@@ -243,39 +204,16 @@

### 为什么 Dashboard 是纯 HTML + vanilla JS,不用 React/Vite
### 为什么 projection 不是 source of truth
harness 通过 `npx` 分发,引入 React/Vite 意味着用户每次运行都要拉取大量构建依赖,
破坏零依赖的可移植性。静态 HTML 可以直接从 `file://` 打开,
也可以作为 CI 证据快照分享,不需要任何运行时。
projection 是 raw task record 的命名视图。它解决多消费方口径漂移,但它不能被手写,
也不能绕过任务文件。删除 generated JSON 或 Dashboard 输出后,重新运行 scanner/status
应能得到等价 projection。
app-src 里的 vanilla JS 组件(`DashboardShell`、`SidebarNav`、`TableView` 等)
通过 manifest 按顺序拼接,每个文件 < 600 行,git diff 可读,不需要 webpack/esbuild。
### 为什么 Dashboard 仍是纯 HTML + vanilla JS
### 为什么静态 Dashboard 是只读的
harness 通过 `npx` 分发。引入 React/Vite 会给每次运行带来构建依赖,破坏零依赖的可移植性。
静态 HTML 可以从 `file://` 打开,也可以作为 CI 证据快照分享。
静态 Dashboard 的定位是"可分享的证据快照"——它可以被 CI 生成、离线打开、
发给外部审查者。这些场景下写操作没有安全边界(没有 CSRF/Origin/Host 校验)。
写操作只能在 Workbench 模式下执行,因为 Workbench server 绑定 `127.0.0.1`
并有完整的安全校验链。
### 为什么静态 Dashboard 只读
### 为什么 `harness dev` 和 `harness dashboard` 是两个命令
`harness dashboard` 生成静态只读快照(适合 CI、迁移报告、离线证据)。
`harness dev` 启动本地动态 Workbench server,支持文件 watch、自动刷新和人工确认写操作。
两者的边界是:**静态快照可以分享,动态 Workbench 只能本地用**。
### 为什么文件监听用轮询而不是 fs.watch
`fs.watch` 在 macOS 上对深层目录树有已知的漏报问题,
而 harness 的 docs 目录结构是多层嵌套的 Markdown 文件树。
轮询方案实现简单、行为可预期、不引入 chokidar 等第三方依赖(与零依赖原则一致)。
1 秒轮询 + 250ms debounce 对人类编辑场景足够。
### 为什么不引入 SQLite 或 JSON 数据库
引入 JSON/SQLite 若没有清楚的 authority 边界,会形成 Markdown、JSON、SQLite 三份事实互相漂移。
Git review 对 Markdown/JSON 友好,对 SQLite diff 不友好。
当前规模是"几百任务",generated JSON + indexed in-memory filtering 足够。
决策是:Markdown 是唯一事实源,generated JSON index 是可再生缓存,
SQLite 只在任务数量和查询复杂度超过 JSON 能承载时才考虑引入,
且只能作为可再生查询缓存,不能手写、不能作为权威事实源。
静态 Dashboard 没有安全边界,适合分享和审查。写操作只能在本地 Workbench 模式执行,
因为 Workbench server 能检查 host、origin、CSRF、Git 状态和 allowed paths。

@@ -60,4 +60,4 @@ # 01 — System Overview

A["📦 Package\nPublished npm package\n(CLI + templates + standards + Presets)"]
B["📁 Target Repo\nUser's project repository\n(doc tree + state files)"]
C["⚙️ Runtime\nRuntime engine\n(scan + check + generate)"]
B["📁 Target Repo\nUser's project repository\n(Harness workspace + state files)"]
C["⚙️ Runtime\nRuntime engine\n(command registry + use cases + projection views)"]
D["👤 Human\nHuman review entry point\n(Dashboard + review confirmation)"]

@@ -72,4 +72,4 @@

- **Package**: What you `npm install` — contains the CLI, templates, standards docs, and Preset packages
- **Target Repo**: Your project, where harness creates a `coding-agent-harness/` tree to record task state
- **Runtime**: The CLI runtime that scans the doc tree, validates compliance, and generates the Dashboard
- **Target Repo**: Your project, where harness creates a `coding-agent-harness/` workspace to record task state
- **Runtime**: The CLI runtime that uses command definitions, application use cases, repository ports, and semantic projections to produce reviewable views
- **Human**: Views the Dashboard in a browser, performs review confirmation in the Workbench

@@ -92,3 +92,3 @@

PKG --> CLI["harness CLI\ndist/harness.mjs\nSingle command entry point"]
PKG --> Lib["Core library\nscripts/lib/\n~30 modules, 6 functional layers"]
PKG --> Lib["Core library\nscripts/lib/\ncommand registry + application use cases + domain kernel"]
PKG --> Templates["Templates\ntemplates/\nTask scaffolds + module-root templates"]

@@ -111,3 +111,3 @@ PKG --> References["Operating standards\nreferences/\nSpec docs that can be copied to target repos"]

REPO --> Harness["coding-agent-harness/\nHarness workspace"]
REPO --> Docs["docs/\nReference docs and project standards"]
REPO -.-> LegacyDocs["docs/\nLegacy migration input\nnot current runtime state"]

@@ -117,4 +117,5 @@ Harness --> Planning["planning/\nTask directory + module directory"]

Harness --> Walkthrough["task-local walkthrough.md\nCloseout evidence"]
Docs --> Reference["coding-agent-harness/governance/standards/\nLocal operating standards (copied from Package)"]
Docs --> Governance["01-GOVERNANCE/\nLesson library"]
Harness --> Reference["governance/standards/\nLocal operating standards (copied from Package)"]
Harness --> Governance["governance/lessons/\nLesson library"]
LegacyDocs -.-> Migration["legacy migration\nread only as migration evidence"]
```

@@ -128,2 +129,6 @@

If the target repository still has a root `docs/` tree, treat it as legacy
migration input or historical evidence. It is not the v2 runtime source for
Reference docs or project standards.
### What the Runtime does

@@ -135,11 +140,12 @@

RT --> Scan["Scan\nRead all task files\nParse state / phases / review / Lessons"]
RT --> Check["Check\n9 validators verify compliance\nOutput failures / warnings"]
RT --> Generate["Generate\nOutput Dashboard HTML + JSON index\nOutput governance index tables"]
RT --> Migrate["Migrate\nAnalyze legacy project gaps\nGenerate migration action queue"]
RT --> Lifecycle["Lifecycle\nExecute state transition commands\nWrite Governance Sync"]
RT --> Commands["Command registry\nCommandDefinition[] binds CLI actions"]
RT --> UseCases["Application use cases\nTaskOperations orchestrates lifecycle and review"]
RT --> Ports["Ports\nTaskRepository / HarnessTransaction isolate filesystem writes"]
RT --> Projection["Semantic projection\nTaskLifecycleProjection / DashboardTaskView"]
RT --> Render["Generate\nOutput Dashboard HTML + JSON index\nOutput governance index tables"]
```
The Runtime is **stateless** — every run re-reads from Markdown files from scratch,
caching no intermediate state (except file watching in `harness dev`).
The Runtime is **stateless** — every run re-reads from Markdown files from scratch.
Dashboard, Workbench queues, and governance indexes are derived projection views
from task source files, not a second source of truth.

@@ -146,0 +152,0 @@ ---

# 02 — Code Module Dependencies
## Level 0 — Where's the entry point
## Level 0 — Where the entry point is

@@ -9,256 +9,209 @@ All commands come through a single file:

flowchart LR
User["User / Agent\n$ harness <command> [target]"] -->|"parse args + dispatch"| Entry["dist/harness.mjs\nSingle CLI entry point"]
User["User / Agent\n$ harness <command> [target]"] --> Entry["dist/harness.mjs\nCLI entry"]
Entry --> Registry["Command Registry\nCommandDefinition[]"]
Registry --> Handler["Command handlers\ncommands/*.mjs"]
```
`harness.mjs` does two things: parses command-line arguments, then dispatches to the
corresponding command module or calls the core library directly.
It contains no business logic itself.
`harness.mjs` only passes arguments into the command registry. Command name,
usage, flags, positionals, and handler are registered in `CommandDefinition`;
help output is generated from the same registry. Adding a command is no longer
an if/else and hand-written help edit across several places.
---
## Level 1 — How commands are dispatched
## Level 1 — Current layering
After the lower-level refactor, the public architecture is no longer a flat set
of six functional modules. It is a facade-first ports/adapters structure. The
old scanner, governance-sync, and preset runner still exist, but they are
infrastructure / legacy adapters rather than internals that CLI or Dashboard
should call around the business layer.
```mermaid
flowchart TD
Entry["dist/harness.mjs"]
flowchart TB
Adapter["Adapters\nCLI / Dashboard Workbench / Preset / Migration"]
App["Application use cases\nTaskOperations / module governance / workbench actions"]
Domain["Domain kernel\nstate policy / review gates / module ownership / preset model"]
Ports["Ports\nTaskRepository / HarnessTransaction / projections"]
Infra["Infrastructure + legacy adapters\nscanner / markdown / fs / git / governance-sync / preset runner"]
Entry -->|"dashboard\ndev"| DashCmd["dist/commands/dashboard-command.mjs\nDashboard generation + dynamic serving"]
Entry -->|"migrate-plan\nmigrate-run\nmigrate-verify"| MigCmd["dist/commands/migration-command.mjs\nMigration three-phase commands"]
Entry -->|"new-task / task-start\ntask-phase / task-review\ntask-complete / task-tombstone"| TaskCmd["dist/commands/task-command.mjs\nTask lifecycle commands"]
Entry -->|"preset catalog\npreset install\npreset uninstall"| PresetCmd["dist/commands/preset-command.mjs\nPreset management commands"]
Entry -->|"check / status / init\ngovernance / lesson-promote\n..."| Core["dist/lib/harness-core.mjs\n(called directly)"]
Adapter --> App
App --> Domain
App --> Ports
Ports --> Infra
Infra --> Ports
```
Four command modules each own one domain; other commands call `harness-core.mjs` directly.
Dependencies point inward: adapters own protocol and presentation, application
use cases own business actions, the domain owns rules, ports define seams, and
infrastructure implements file-system, Git, Markdown, and legacy scanning.
**Why this split**: Command modules handle commands with complex interaction logic
(multi-step, reading/writing multiple files, user prompts), while simple query commands
(`check`, `status`) are cleaner calling the core library directly.
---
## Level 2 — What is harness-core.mjs
## Level 2 — Command Registry
`harness-core.mjs` is a **facade** — it contains no business logic itself,
it just re-exports everything from all modules under `lib/`.
The benefit of this design: external code only needs to
`import from "./lib/harness-core.mjs"` to get all functionality,
without knowing which sub-module something lives in.
```mermaid
flowchart TD
Core["harness-core.mjs\n(pure re-export facade)"]
Core --> G1["① Core utilities layer\ncore-shared + markdown-utils"]
Core --> G2["② Task scanning layer\ntask-scanner + review-model + lesson-candidates"]
Core --> G3["③ Check and governance layer\ncheck-profiles + governance-sync + governance-index"]
Core --> G4["④ Dashboard layer\ndashboard-data + dashboard-writer + workbench"]
Core --> G5["⑤ Task lifecycle layer\ntask-lifecycle + review-gates + review-confirm"]
Core --> G6["⑥ Migration and Preset layer\nmigration-planner + preset-registry + tombstone"]
flowchart LR
Harness["scripts/harness.mts"] --> Dispatch["dispatchCommand()"]
Dispatch --> Defs["CommandDefinition[]"]
Defs --> Dash["dashboard / dev"]
Defs --> Migration["migrate-*"]
Defs --> Task["new-task / task-*"]
Defs --> Preset["preset *"]
Defs --> Module["module *"]
Defs --> Core["check / status / init / governance"]
```
Let's expand each layer.
`commands/registry.mts` is the source of truth for the command surface. It
declares each command's name, usage, flag schema, positionals, and handler.
Complex commands still dispatch into `dashboard-command.mts`,
`migration-command.mts`, `task-command.mts`, `preset-command.mts`, and
`module-command.mts`, but registration and help text are not scattered through
the entry file.
---
## Level 3 — Six functional layers in detail
## Level 2 — Task read path: TaskRepository
### ① Core utilities layer
```mermaid
flowchart TD
Consumer["status / dashboard / workbench / lifecycle / task-index"]
Repo["TaskRepository\nlist / get / resolve / readMaterials"]
Scanner["legacy scanner\ncollectTasks + task discovery"]
Files["Markdown task package\nINDEX / task_plan / progress / review / visual_map"]
These two modules are the foundation for all other modules — almost every module imports them:
```mermaid
flowchart LR
CoreShared["core-shared.mjs\nPath resolution / constant enums\nFile read/write / locale handling\nTemplate rendering"]
MarkdownUtils["markdown-utils.mjs\nMarkdown table extraction\nRow updates / column lookup\nDependency list splitting"]
Consumer --> Repo
Repo --> Scanner
Scanner --> Files
```
`core-shared` defines all allowed enum values — it's the "type system" for the whole system:
`TaskRepository` is the read seam. The first implementation still wraps the
existing scanner; the point is not to rewrite scanning immediately, but to stop
callers from depending on task discovery internals. It exposes four capabilities:
| Enum | Allowed values |
| Method | Purpose |
| --- | --- |
| `allowedTaskStates` | `not_started / planned / in_progress / review / blocked / done` |
| `allowedTaskBudgets` | `simple / standard / complex` |
| `allowedPhaseStates` | `planned / in_progress / review / blocked / done / skipped` |
| `allowedCapabilities` | `core / module-parallel / subagent-worker / adversarial-review / ...` |
| `list(query)` | List tasks with state, module, queue, preset, review, and lesson filters |
| `get(ref)` | Read one task by id or path |
| `resolve(ref)` | Resolve a task reference to its directory and `task_plan.md` path |
| `readMaterials(ref)` | Read reviewable Markdown files from the task package |
`markdown-utils` provides structured operations on Markdown tables — this is the technical
foundation that lets the whole system derive state from Markdown files.
This facade lets future scanner internals change without editing Dashboard,
Workbench, check, and lifecycle callers one by one.
---
### ② Task scanning layer
## Level 2 — Task write path: TaskOperations
Responsible for reading all files under `coding-agent-harness/planning/tasks/` and parsing them into
structured data:
```mermaid
flowchart TD
TaskScanner["task-scanner.mjs\nScans all task directories\nParses state / budget / phases / metadata"]
CLI["CLI task commands"]
Workbench["Dashboard Workbench"]
Ops["TaskOperations\ncreate / start / review / complete / confirmReview / delete / archive / supersede / reopen / lessonSediment"]
Repo["TaskRepository"]
LegacyWrite["legacy lifecycle / tombstone / lesson writers"]
TaskScanner --> ReviewModel["task-review-model.mjs\nReview confirmation parsing\nLifecycle queue derivation\nTombstone parsing"]
TaskScanner --> LessonCandidates["task-lesson-candidates.mjs\nLesson candidate status parsing\nDecision completion determination"]
ReviewModel --> CoreShared
ReviewModel --> MarkdownUtils
TaskScanner --> CoreShared
TaskScanner --> MarkdownUtils
CLI --> Ops
Workbench --> Ops
Ops --> Repo
Ops --> LegacyWrite
```
`task-review-model` contains several key **derivation functions** — they don't read files,
they compute new state from already-parsed data:
`TaskOperations` is the application use-case layer. Both CLI and Dashboard
Workbench go through it for task actions, so rules such as review confirmability,
task completion, and open blocking findings do not fork across entry points.
| Function | Input | Output |
| --- | --- | --- |
| `deriveLifecycleState()` | taskState + reviewStatus + tombstone | `lifecycleState` (queue classification) |
| `deriveTaskQueues()` | lifecycleState + materials + lessons | `taskQueues[]` (which queues it belongs to) |
| `deriveReviewQueueState()` | findings + confirmation | `reviewQueueState` |
| `parseTaskTombstone()` | task_plan.md content | soft-delete / merge / superseded state |
The current implementation can still call legacy lifecycle writers to update
Markdown. That is an intentional transition state: first make the use-case
boundary hard, then move lower-level writers behind a unified transaction.
These derivation functions are **pure functions** — same input always produces same output,
making them easy to test and debug.
---
### ③ Check and governance layer
## Level 2 — Write transaction: HarnessTransaction
Responsible for validating compliance and maintaining atomic writes to global indexes:
```mermaid
flowchart TD
CheckProfiles["check-profiles.mjs\nbuildStatus() orchestrates 9 validators\nReturns failures + warnings + tasks"]
flowchart LR
UseCase["write use case / preset action / governance rebuild"]
ChangeSet["ChangeSet\nwrites / deletes / generated surfaces / commit policy"]
Tx["HarnessTransaction\nplan + apply"]
GovSync["governance-sync legacy adapter\nlock + allowed paths + git commit"]
Git["Git history"]
CheckProfiles --> V1["validateCapabilities\nCapability registry consistency"]
CheckProfiles --> V2["validateReviewSchema\nreview.md structure"]
CheckProfiles --> V3["validateVisualMaps\nvisual_map compliance"]
CheckProfiles --> V4["validatePlanContracts\nTask contract markers"]
CheckProfiles --> V5["validateTaskPresetContracts\nPreset contracts"]
CheckProfiles --> V6["validateContextDocs\nContext doc completeness"]
CheckProfiles --> V7["validateGovernanceTableBoundaries\nTable boundaries"]
CheckProfiles --> V8["validateSubagentAuthorization\nSubagent authorization"]
CheckProfiles --> V9["validateTaskCompletionConsistency\nCompletion consistency"]
CheckProfiles --> GitSummary["git-status-summary.mjs\nGit status summary (dirty files etc.)"]
GovSync["governance-sync.mjs\nAtomic lock + row-level update + Git commit\n(auto-called on task state changes)"]
GovIndex["governance-index-generator.mjs\nRebuilds global index tables\n(manually triggered)"]
GovIndex --> GovSync
UseCase --> ChangeSet
ChangeSet --> Tx
Tx --> GovSync
GovSync --> Git
```
**Important distinction**: `governance-sync` and `check-profiles` have no dependency on each other.
- `check-profiles`: read-only, validates state, writes no files
- `governance-sync`: write-only, updates the ledger, does no validation
`HarnessTransaction` gives write planning, allowed paths, generated surfaces,
dirty-tree checks, dry-run behavior, and Git commit results a named boundary.
Its core types are:
---
| Type | Role |
| --- | --- |
| `ChangeSet` | Declares writes, deletes, generated surfaces, and commit policy |
| `TransactionPlan` | Normalized plan with allowed paths, generated surfaces, Git state, and conflicts |
| `TransactionResult` | Success/failure, written paths, commit result, and lock release state |
### ④ Dashboard layer
This is not a new source of truth. Transactions manage write safety and commit
boundaries; task facts still come from Markdown under `coding-agent-harness/`.
Responsible for converting scan results into an HTML Dashboard:
```mermaid
flowchart TD
DashData["dashboard-data.mjs\nbuildDashboardBundle()\nCollects status + documents + tables + graph + adoption"]
DashData --> CheckProfiles["check-profiles.mjs\n(calls buildStatus)"]
DashData --> DashWriter["dashboard-writer.mjs\nWrites HTML + JSON files\n(static snapshot mode)"]
DashData --> StatusRenderer["status-dashboard-renderer.mjs\nRenders status summary text"]
DashWorkbench["dashboard-workbench.mjs\nDev dynamic serving\nHTTP server + file watching + auto-refresh\n(harness dev command)"]
```
`DashWorkbench` and `DashData` / `DashWriter` are **independent**:
- `DashData` + `DashWriter`: generates static snapshots (read-only)
- `DashWorkbench`: starts a local HTTP server, supports Workbench write operations
---
### ⑤ Task lifecycle layer
## Level 2 — Semantic projection: Task Semantic Projection
Responsible for executing all task state transition commands:
```mermaid
flowchart TD
TaskLifecycle["task-lifecycle.mjs\nLifecycle command implementations\nnew-task / task-start / task-phase\ntask-review / task-complete"]
Raw["raw task record\nstate / reviewStatus / taskQueues / closeoutStatus / lessons"]
Projection["Task semantic projection"]
Lifecycle["TaskLifecycleProjection\ncanonical task lifecycle fields"]
Dashboard["DashboardTaskView\nswimlane stage + display affordances"]
Review["ReviewWorkbenchQueueView\nqueue / confirmability / blocking reasons"]
TaskLifecycle --> ReviewGates["task-lifecycle/review-gates.mjs\nGate validation logic\n(checks before entering review)"]
TaskLifecycle --> ReviewConfirm["task-lifecycle/review-confirm.mjs\nHuman confirmation execution\n(Workbench internal write entry)"]
TaskLifecycle --> TextUtils["task-lifecycle/text-utils.mjs\nText append utilities\n(appending content to Markdown files)"]
TaskLifecycle --> GovSync["governance-sync.mjs\nSync ledger on state changes"]
TaskLifecycle --> MigPreset["task-migration-preset.mjs\nMigration Preset context injection"]
ReviewConfirm --> GitGate["review-confirm-git-gate.mjs\nGit atomic commit gate\n(writes human confirmation block + commit)"]
Raw --> Projection
Projection --> Lifecycle
Projection --> Dashboard
Projection --> Review
```
Human confirmation is the most special write operation in the lifecycle layer: it is triggered
only through the local Workbench and requires Git atomic commit behavior
(see design decisions in [01-system-overview.md](01-system-overview.md)).
Task semantic projection solves the problem where status, Dashboard, Workbench,
and generated indexes each interpret the same review concept differently. It
wraps the raw task record into three view models:
---
| Projection | Consumers | Must not do |
| --- | --- | --- |
| `TaskLifecycleProjection` | status JSON, task-index, Dashboard, governance rows | write back to source files |
| `DashboardTaskView` | task list, detail drawer, swimlane | reinvent lifecycle rules in the frontend |
| `ReviewWorkbenchQueueView` | review workbench, bulk confirmation actions, review queue views | recalculate blocking risks from Markdown |
### ⑥ Migration and Preset layer
Projection may be written as generated JSON or cached, but it is not
authoritative. Deleting it must not change task semantics; it must be rebuildable
from the task files.
```mermaid
flowchart TD
PresetReg["preset-registry.mjs\nReads presets/ YAML\nValidates package completeness\nLayered discovery (project / user / bundled)"]
PresetEngine["preset-engine.mjs\nExecutes Preset entrypoints\n(template / script / check types)"]
PresetAudit["preset-audit-contracts.mjs\nValidates Preset contract completeness"]
PresetResource["preset-resource-contracts.mjs\nValidates Preset resource declarations"]
MigPlanner["migration-planner.mjs\nAnalyzes target repo gaps\nGenerates migration action queue"]
MigSupport["migration-support.mjs\nSession management / locale detection\nGit status check / full-cutover verification"]
Tombstone["task-tombstone-commands.mjs\nSoft-delete / merge / reopen commands"]
LessonSed["task-lesson-sedimentation.mjs\nLesson sedimentation task creation"]
LessonMaint["lesson-maintenance.mjs\nLesson library maintenance"]
TaskIndex["task-index.mjs\nTask index generation"]
MigPlanner --> MigSupport
PresetEngine --> PresetReg
```
---
## Complete dependency map (reference)
## Level 3 — Where legacy modules still fit
If you've understood the layering above, this diagram serves as a lookup index:
Many file names still live under `scripts/lib/`. This keeps the refactor
verifiable and reversible instead of moving everything at once. Their current
roles are:
```mermaid
flowchart TD
Entry["harness.mjs"] --> DashCmd & MigCmd & TaskCmd & PresetCmd & Core["harness-core.mjs"]
| Module | Current role |
| --- | --- |
| `task-scanner.mts` / `task-review-model.mts` | Legacy read implementation behind TaskRepository |
| `governance-sync.mts` | Legacy write / commit adapter behind HarnessTransaction |
| `task-lifecycle.mts` | Still performs some Markdown writes while TaskOperations and transaction adoption continue |
| `dashboard-data.mts` / `dashboard-workbench.mts` | Dashboard adapter and projection consumer |
| `preset-runner.mts` | Preset adapter, converging toward ChangeSet / transaction execution |
Core --> CoreShared & MarkdownUtils
Core --> TaskScanner --> ReviewModel & LessonCandidates
Core --> CheckProfiles --> GitSummary
Core --> GovSync
Core --> GovIndex --> GovSync
Core --> DashData --> DashWriter & StatusRenderer
Core --> DashWorkbench
Core --> TaskLifecycle --> ReviewGates & ReviewConfirm & TextUtils & GovSync & MigPreset
ReviewConfirm --> GitGate
Core --> PresetReg
Core --> PresetEngine --> PresetReg
Core --> MigPlanner --> MigSupport
Core --> Tombstone
Core --> LessonSed
Core --> LessonMaint
Core --> TaskIndex
```
When reading the code, first ask which adapter calls which use case, then which
port the use case depends on. Only port legacy implementations should directly
touch scanner internals, Markdown file details, or governance-sync.
---
## Level 2 — Module naming patterns
## Next
Understanding naming patterns helps you locate code quickly:
| Prefix / suffix | Meaning | Examples |
| --- | --- | --- |
| `task-` | Task-related | `task-scanner`, `task-lifecycle`, `task-review-model` |
| `dashboard-` | Dashboard-related | `dashboard-data`, `dashboard-writer`, `dashboard-workbench` |
| `governance-` | Governance / ledger-related | `governance-sync`, `governance-index-generator` |
| `migration-` | Migration-related | `migration-planner`, `migration-support` |
| `preset-` | Preset-related | `preset-registry`, `preset-engine`, `preset-audit-contracts` |
| `check-` | Validators | `check-profiles`, `check-module-parallel` |
| `-command.mjs` | CLI command modules | `task-command`, `dashboard-command` |
| `-utils.mjs` | Utility functions | `markdown-utils`, `text-utils` |
| `-gates.mjs` | Gate logic | `review-gates`, `review-confirm-git-gate` |
- For task lifecycle, read [03-task-lifecycle.md](03-task-lifecycle.md)
- For checks and governance, read [04-check-and-governance.md](04-check-and-governance.md)
- For Dashboard data flow and projection, read [05-data-flow.md](05-data-flow.md)

@@ -5,3 +5,3 @@ # 03 — Task Lifecycle

A task goes through six states from creation to closeout:
A task moves from creation to finalization through execution states, a human confirmation point, and blocked/rework paths. This finalization is separate from the closeout packet an agent prepares before Human Review:

@@ -12,3 +12,4 @@ ```mermaid

C --> D["review\nAwaiting human review"]
D --> E["done\nCloseout complete"]
D --> H["human_confirmed\nHuman confirmed"]
H --> E["done\nFinalization complete"]
C -->|"external block"| F["blocked"] -->|"unblocked"| C

@@ -30,3 +31,4 @@ D -->|"sent back for rework"| C

R["review\nAwaiting human review"]
D["done\nCloseout complete"]
HC["human_confirmed\nHuman confirmed"]
D["done\nFinalization complete"]
BL["blocked\nBlocked by external dependency"]

@@ -36,3 +38,4 @@

IP -->|"harness task-review"| R
R -->|"Dashboard workbench\nhuman confirmation"| D
R -->|"Dashboard workbench\nhuman confirmation"| HC
HC -->|"harness task-complete"| D
IP -->|"harness task-phase --blocked"| BL

@@ -45,3 +48,6 @@ BL -->|"harness task-start"| IP

be triggered through the local Dashboard workbench write endpoint, and it writes an
auditable confirmation block with Git `user.name` / `user.email`.
auditable confirmation block with Git `user.name` / `user.email`. The agent completion
boundary is preparing evidence, `review.md`, `walkthrough.md`, lesson decisions, and
residual risk before Human Review; the post-confirmation `task-complete` command is the
final lifecycle transition.

@@ -48,0 +54,0 @@ ---

@@ -7,11 +7,18 @@ # 05 — Data Flow: From Markdown to Dashboard

flowchart LR
A["📄 Markdown files\n(source files under coding-agent-harness/)"]
B["⚙️ Scanner\n(parse + validate)"]
C["📊 Dashboard\n(HTML + JSON)"]
Source["Markdown source of truth\ncoding-agent-harness/"]
Repository["TaskRepository\ncompatibility read seam"]
Status["status model\nscanner + validators"]
Projection["Task semantic projection\nunified lifecycle / review / queue semantics"]
Dashboard["Dashboard / Workbench / generated indexes"]
A -->|"read"| B -->|"generate"| C
Source --> Repository
Repository --> Status
Status --> Projection
Projection --> Dashboard
```
All data comes from Markdown files — no database, no external services.
Every run re-reads from the filesystem from scratch, caching no intermediate state.
Authoritative facts live only in Markdown files under `coding-agent-harness/`.
Scanner output, status JSON, Dashboard bundles, generated indexes, and
projections are rebuildable views. They may be cached or written to disk, but
they must not become a second source of truth.

@@ -26,160 +33,142 @@ ---

Sources --> TP["task_plan.md\nBudget / title / metadata / Preset info\nTask Contract marker"]
Sources --> IDX["INDEX.md\nTask Audit Metadata / review confirmation"]
Sources --> TP["task_plan.md\nBudget / title / Tombstone / Preset info / Task Contract"]
Sources --> PR["progress.md\nCurrent state / operation log"]
Sources --> VM["visual_map.md\nPhase list / completion / evidence status"]
Sources --> RV["review.md\nFindings table / human confirmation block"]
Sources --> RV["review.md\nAgent review submission / findings"]
Sources --> BR["brief.md\nTask summary"]
Sources --> LC["lesson_candidates.md\nLesson candidates / decision state"]
Sources --> HL["Harness-Ledger.md\nGlobal ledger (all tasks summarized)"]
Sources --> ES["execution_strategy.md\nSubagent authorization state"]
Sources --> WO["walkthrough.md\nCloseout evidence"]
```
Generated `Harness-Ledger.md`, task-index, module-index, Closeout index, and
Dashboard JSON are not hand-written sources. They support browsing, review, and
context recovery, but must be rebuildable from the source files.
---
## Level 2 — How the Scanner processes these files
## Level 2 — How TaskRepository reads tasks
The Scanner layer (`task-scanner.mjs` + `task-review-model.mjs`) parses raw Markdown
into structured objects.
### collectTasks() discovery flow
```mermaid
flowchart TD
CT["collectTasks()"]
Repo["TaskRepository"]
List["list(query)"]
Get["get(ref)"]
Resolve["resolve(ref)"]
Materials["readMaterials(ref)"]
Scanner["legacy scanner\ncollectTasks / task discovery"]
CT --> Discover["listTaskPlanPaths()\nScans two root directories:\ncoding-agent-harness/planning/tasks/\ncoding-agent-harness/planning/modules/\nFilters out template and archive directories"]
Repo --> List
Repo --> Get
Repo --> Resolve
Repo --> Materials
List --> Scanner
Get --> Scanner
Resolve --> Scanner
Materials --> Scanner
```
Discover --> ReadFiles["For each task directory, reads 9 files:\ntask_plan / brief / progress\nreview / visual_map\nexecution_strategy\nlesson_candidates / findings / context"]
`TaskRepository` wraps the existing scanner. Callers see task records and task
materials, not `listTaskPlanPaths()`, directory exclusion rules, or legacy
visual-map fallback internals.
ReadFiles --> Parse["Parse each file"]
### Task discovery flow
Parse --> P1["parseTaskBudget()\nExtract budget from task_plan.md"]
Parse --> P2["parseTaskState()\nExtract state from progress.md"]
Parse --> P3["parsePhases()\nExtract phase list from visual_map.md"]
Parse --> P4["parseAgentReviewSubmission()\nExtract Agent submission state from review.md"]
Parse --> P5["parseReviewConfirmation()\nExtract human confirmation block from review.md"]
Parse --> P6["parseLessonCandidateStatus()\nExtract decision state from lesson_candidates.md"]
Parse --> P7["parseTaskTombstone()\nExtract soft-delete state from task_plan.md"]
P1 & P2 & P3 & P4 & P5 & P6 & P7 --> Derive["Derivation (pure functions)"]
Derive --> LS["deriveLifecycleState()\nDerive lifecycleState from combined inputs"]
Derive --> QS["deriveTaskQueues()\nDetermine which queues the task belongs to"]
Derive --> RQS["deriveReviewQueueState()\nDerive reviewQueueState"]
```mermaid
flowchart TD
CT["collectTasks()"]
CT --> Discover["Scan\ncoding-agent-harness/planning/tasks/\ncoding-agent-harness/planning/modules/<key>/tasks/"]
Discover --> Read["Read task package Markdown\nINDEX / task_plan / brief / progress / review / visual_map / lesson_candidates / findings / walkthrough"]
Read --> Parse["Parse raw fields"]
Parse --> Status["Assemble status task record"]
```
### parseTaskState() format
The scanner still parses Markdown tables, state, phases, review submission,
confirmation, lesson decisions, and tombstones. The post-refactor difference is
that every UI or command surface should not re-interpret those raw fields on its own.
Extracts state from the first line after the `## Current Status` or `## Status` heading
in `progress.md`:
---
```markdown
## Current Status
## Level 2 — What status output contains
in_progress
```
`buildStatus()` assembles machine-readable state from repository/scanner output
and validator output:
Supports Chinese aliases (`进行中` → `in_progress`). Falls back to legacy table parsing
mode if the format doesn't match expectations.
### parsePhases() table format
Finds the table with a `Phase ID` column header in `visual_map.md` and extracts 9 fields:
```markdown
| Phase ID | Depends On | State | Completion | Output | Required Evidence | Evidence Status | Blocking Risk | Owner / Handoff |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| P1 | — | done | 100 | ... | E-001 | present | low | coordinator |
```mermaid
flowchart TD
Status["buildStatus()"]
Status --> Tasks["tasks[]\nraw task records + semantic projection"]
Status --> Failures["failures[]"]
Status --> Warnings["warnings[]"]
Status --> Caps["capabilities[]"]
Status --> Git["git status summary"]
Status --> Summary["summary metrics"]
```
The dependency field supports multiple values separated by commas, semicolons, or `&`.
Each task record still keeps raw fields such as `state`, `reviewStatus`,
`reviewQueueState`, `taskQueues`, `closeoutStatus`, `materialsReady`, and
`lessonCandidateDecisionComplete`. These fields are useful for debugging, but
Dashboard and generated governance rows should prefer semantic projection.
---
## Level 2 — What buildStatus() assembles from Scanner results
## Level 2 — Task Semantic Projection
```mermaid
flowchart TD
BS["buildStatus()"]
Raw["raw task record"]
Projection["buildTaskSemanticProjection()"]
Lifecycle["TaskLifecycleProjection"]
DashView["DashboardTaskView"]
ReviewView["ReviewWorkbenchQueueView"]
BS --> Tasks["tasks[]\nComplete structured data for each task\n(see field list below)"]
BS --> Failures["failures[]\nHard failure list"]
BS --> Warnings["warnings[]\nSoft warning list"]
BS --> Caps["capabilities[]\nCapability registry state"]
BS --> Git["git\nGit status summary (dirty files etc.)"]
BS --> Summary["summary\nbriefCoverage / visualMapCoverage\nfullCutoverEligible and other aggregate metrics"]
Raw --> Projection
Projection --> Lifecycle
Projection --> DashView
Projection --> ReviewView
```
**Complete fields of a task object**:
Projection wraps one raw task record into three explicit views:
| Field | Meaning |
| --- | --- |
| `path` | Relative path |
| `title` | Task title |
| `state` | Task state (`in_progress / done / ...`) |
| `stateSource` | State source (`valid / invalid`) |
| `budget` | Budget level (`simple / standard / complex`) |
| `lifecycleState` | Derived lifecycle state |
| `reviewQueueState` | Review queue state |
| `taskQueues[]` | List of queues the task belongs to |
| `phases[]` | Phase list (with id / state / completion / evidenceStatus) |
| `closeoutStatus` | Closeout status |
| `tombstone` | Soft-delete information |
| `briefSource` | Brief source (`standalone / missing / ...`) |
| `visualMapSource` | Visual map source (`canonical / legacy / missing`) |
| `taskPreset` | Preset ID used |
| `presetVersion` | Preset version |
| `handoffs` | Handoff information array |
| `lessonCandidateDecisionComplete` | Whether Lesson decision is complete |
| Projection | Core fields | Consumers |
| --- | --- | --- |
| `TaskLifecycleProjection` | `state`, `lifecycleState`, `reviewStatus`, `reviewQueueState`, `closeoutStatus`, `taskQueues`, `materialsReady`, `reviewSubmitted`, `deletionState` | status JSON, task-index, generated governance rows |
| `DashboardTaskView` | `visibleInSwimlane`, `swimlaneStage`, `needsEvidence`, `reasonCode`, `reasonMessage` | Dashboard task list, detail drawer, swimlane |
| `ReviewWorkbenchQueueView` | `primaryQueue`, `humanConfirmable`, `blocked`, `needsMaterials`, `confirmed`, `finalized`, `readyForCloseout`, `reasonCodes` | Review Workbench, bulk confirmation, review queue |
This boundary prevents the same task from having different meanings in top-line
stats, lifecycle workbench, swimlanes, and review tables. The frontend may decide
layout, color, and filtering, but it must not redefine whether a task is
review-ready, blocked, confirmed, or finalized.
---
## Level 3 — What buildDashboardBundle() adds on top of status
## Level 3 — How the Dashboard bundle uses projection
`buildDashboardBundle()` calls `buildStatus()` then additionally collects four types of data:
```mermaid
flowchart TD
Bundle["buildDashboardBundle()"]
Bundle --> Status["status\nwith semanticProjection"]
Bundle --> Documents["documents[]\nMarkdown document content"]
Bundle --> Tables["tables[]\nMarkdown table structure"]
Bundle --> Graph["graph\ntask / phase / module dependencies"]
Bundle --> Adoption["adoption\nmigration adoption state"]
Bundle --> Status["status\n(from buildStatus)"]
Bundle --> Documents["documents[]\nContent of all Markdown documents\nEach document: id / path / title / type / content"]
Bundle --> Tables["tables[]\nMarkdown tables extracted from documents\nEach table: column definitions + row data"]
Bundle --> Graph["graph\nTask dependency graph\nnodes (tasks/phases/modules/steps)\nedges (dependency/containment/handoff relationships)"]
Bundle --> Adoption["adoption\nMigration adoption state analysis\n(warnings categorized with priority and fix suggestions)"]
Status --> UI["Dashboard UI"]
Status --> Workbench["Workbench API responses"]
Status --> Generated["generated task rows"]
```
Dashboard bundle adds documents, tables, graph, and adoption analysis on top of
status. Task lifecycle, review queue, swimlane stage, and confirmability should
come from projection, not from mixing raw `state`, `reviewStatus`, and
`taskQueues` again in `app.js` or Workbench handlers.
### documents collection scope
Which files `collectMarkdownDocuments()` collects:
`collectMarkdownDocuments()` still collects fixed governance files, task package
files, module files, and lesson files. Those documents support human reading and
table browsing; they do not change task lifecycle semantics.
```mermaid
flowchart TD
Collect["collectMarkdownDocuments()"]
Collect --> Fixed["Fixed paths (collected when they exist)\nHarness-Ledger.md\nharness.yaml modules.items\ncoding-agent-harness/planning/modules/Module-Registry.md\ncoding-agent-harness/governance/regression/Regression-SSoT.md\ncoding-agent-harness/governance/generated/Closeout-Index.md"]
Collect --> Walkthrough["All .md files under coding-agent-harness/planning/tasks/<task>/\n(excluding _archive/ and files starting with _)"]
Collect --> TaskDocs["Under each task directory:\nbrief / task_plan / execution_strategy\nvisual_map / lesson_candidates\nprogress / review / findings\nreferences/INDEX.md / artifacts/INDEX.md"]
Collect --> ModuleDocs["Under coding-agent-harness/planning/modules/:\nroot brief.md / module_plan.md for each module\ntask contracts under tasks/<task>/"]
Collect --> Lessons["All .md files under 01-GOVERNANCE/lessons/"]
```
After collection, uniformly filters out `_archive/`, `_task-template/`, and
`_optional-structures/` paths.
### graph data structure
The graph contains two types of elements:
- **nodes**: task nodes, phase nodes, module nodes, step nodes
- **edges**: dependency relationships (phase → phase), containment relationships (task → phase),
handoff relationships (step → step)
If the source node of a dependency doesn't exist, a virtual node of type
`external-dependency` is created.
---

@@ -193,4 +182,4 @@

SC["harness dashboard\n--out-dir ./out"]
SC --> SH["index.html\n(all resources inlined)"]
SC --> SJ["dashboard-data.json\n(for external tools to read)"]
SC --> SH["index.html\ninlined resources"]
SC --> SJ["dashboard-data.json"]
SC --> SF["status.json / tables.json\ndocuments.json / graph.json\nadoption.json"]

@@ -201,47 +190,21 @@ end

DC["harness dev"]
DC --> HTTP["Local HTTP server\nlocalhost:PORT"]
DC --> Watch["File watching\nPolling mode, checks every 1 second\nRegenerates after 250ms delay on change"]
HTTP --> Browser["Live browser view\nSupports human confirmation and other write operations"]
DC --> HTTP["Local HTTP server\n127.0.0.1"]
HTTP --> Browser["Live view\nhuman confirmation and other writes"]
Browser --> Ops["TaskOperations\nbusiness actions"]
Ops --> Tx["HarnessTransaction / legacy writers\nscoped Markdown writes + Git commit"]
end
```
**Key boundary**: The static Dashboard is read-only and cannot trigger any write operations.
Only `harness dev` (Workbench mode) can execute write operations like human confirmation
and `task-start`.
The static Dashboard is a shareable evidence snapshot and cannot trigger writes.
Workbench is local-only. Writes must pass host/origin/CSRF checks,
TaskOperations business gates, and scoped write boundaries.
### Dashboard HTML generation
Dashboard HTML is generated via string concatenation (no template engine).
`app.js` is obtained via manifest or direct read — if a manifest exists, it reads and
concatenates multiple source files in order (modular source code under `app-src/`).
`<` in the payload is escaped to `&lt;` to prevent HTML injection.
### File watching implementation
`dashboard-workbench.mjs` file watching uses **polling mode** (`startPollingWatch()`):
- Checks the latest modification time (mtime) of the directory tree every 1000ms
- When a change is detected, triggers regeneration after a 250ms delay (debounce)
- Watch scope: the entire `target.docsRoot`, excluding `.git`, `node_modules`, `tmp`
---
## Level 3 — Core capabilities of markdown-utils.mjs
## Level 3 — Role of markdown-utils.mjs
The technical foundation that lets the whole system derive state from Markdown files is
the table parsing capability provided by `markdown-utils.mjs`:
`markdown-utils.mjs` remains the low-level Markdown table parsing foundation. It
extracts rows, locates columns, reads cells, splits lists, and splits dependencies.
It does not decide whether a task is confirmable, complete, or in the review queue.
| Function | Purpose |
| --- | --- |
| `markdownTableRows()` | Extract all table rows |
| `parseAllMarkdownTables()` | Parse all tables in a document, return array of structured objects |
| `splitMarkdownRow()` | Split row cells (handles escaped pipe characters and code blocks) |
| `tableAfterHeading()` | Locate the table after a specific heading |
| `getCell()` | Get a cell by column name (supports multiple aliases) |
| `splitList()` | Split comma/semicolon/plus-separated lists |
| `splitDependencies()` | Split dependencies, filtering out `none/n/a` placeholders |
**Pipe characters inside code blocks**: `splitMarkdownRow()` tracks code block state —
`|` inside code blocks is not treated as a column separator and the original content is preserved.
---

@@ -251,45 +214,19 @@

### Why Dashboard is plain HTML + vanilla JS, not React/Vite
### Why projection is not source of truth
harness is distributed via `npx`. Introducing React/Vite would mean users pull in large
build dependencies on every run, breaking the zero-dependency portability. Static HTML can
be opened directly from `file://` and shared as a CI evidence snapshot without any runtime.
Projection is a named view over the raw task record. It eliminates semantic drift
across consumers, but it must not be hand-written and must not bypass task files.
After deleting generated JSON or Dashboard output, running scanner/status again
should produce an equivalent projection.
The vanilla JS components in app-src (`DashboardShell`, `SidebarNav`, `TableView`, etc.)
are concatenated in order via manifest. Each file is < 600 lines, git-diff readable,
no webpack/esbuild needed.
### Why Dashboard remains plain HTML + vanilla JS
harness is distributed through `npx`. Introducing React/Vite would make each run
pull build dependencies and break zero-dependency portability. Static HTML can
open from `file://` and can be shared as a CI evidence snapshot.
### Why the static Dashboard is read-only
The static Dashboard's role is "shareable evidence snapshot" — it can be generated by CI,
opened offline, and sent to external reviewers. In these scenarios, write operations have
no security boundary (no CSRF/Origin/Host validation). Write operations can only be
executed in Workbench mode, because the Workbench server binds to `127.0.0.1` and has
a complete security validation chain.
### Why `harness dev` and `harness dashboard` are two separate commands
`harness dashboard` generates a static read-only snapshot (suitable for CI, migration
reports, offline evidence). `harness dev` starts a local dynamic Workbench server with
file watching, auto-refresh, and human-confirmation write operations. The boundary is:
**static snapshots can be shared; the dynamic Workbench is local-only**.
### Why file watching uses polling instead of fs.watch
`fs.watch` has known missed-event issues on macOS for deep directory trees, and harness's
docs directory structure is a multi-level nested Markdown file tree. The polling approach
is simple to implement, has predictable behavior, and doesn't introduce third-party
dependencies like chokidar (consistent with the zero-dependency principle).
1-second polling + 250ms debounce is sufficient for human editing scenarios.
### Why not introduce SQLite or a JSON database
Introducing JSON/SQLite without a clear authority boundary would create drift between
Markdown, JSON, and SQLite as three separate facts. Git review is friendly to Markdown/JSON
diffs but not to SQLite diffs. Current scale is "hundreds of tasks" — generated JSON +
indexed in-memory filtering is sufficient.
The decision: Markdown is the single source of truth, generated JSON index is a
regenerable cache, and SQLite is only considered when task count and query complexity
exceed what JSON can handle — and even then, only as a regenerable query cache, never
hand-written, never as an authoritative source of truth.
Static Dashboard has no safety boundary and is meant for sharing and review.
Writes only run in local Workbench mode, where the server can validate host,
origin, CSRF, Git state, and allowed paths.

@@ -11,5 +11,5 @@ # Agent Installation Guide

Commands in this guide are written with an installed `harness` command. The agent must first check `command -v harness`. If the target environment does not have `harness`, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Run that global install only after explicit approval. If the user does not approve or does not respond, run the same CLI with `npx --yes coding-agent-harness <command>`. Maintainers debugging from the source checkout can replace the same command with `node dist/harness.mjs` after `npm install` or `npm run build:runtime` has generated `dist/`.
Commands in this guide are written with an installed `harness` command. The agent must first check `command -v harness`. If the target environment does not have `harness`, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Run that global install only after explicit approval. If the user does not approve or does not respond, run the same CLI with `npx --yes coding-agent-harness <command>`. Maintainers debugging current source checkout changes can replace the same command with `node run-dist.mjs harness.mjs <command>` from the repository root.
`harness init` does not add this npm package to the target project's dependencies. It only writes Harness docs, templates, and the registry. Delivery summaries must not imply that the target project now has an npm dependency installed. The first `npx` run downloads the package into npm cache; it is not a project dependency or a global command install. When CLI access is needed, keep using `npx --yes coding-agent-harness ...`, a user-approved global `harness`, or `node dist/harness.mjs` from the source checkout.
`harness init` does not add this npm package to the target project's dependencies. It only writes Harness docs, templates, and the registry. Delivery summaries must not imply that the target project now has an npm dependency installed. The first `npx` run downloads the package into npm cache; it is not a project dependency or a global command install. When CLI access is needed, keep using `npx --yes coding-agent-harness ...`, a user-approved global `harness`, or `node run-dist.mjs harness.mjs ...` from the source checkout.

@@ -16,0 +16,0 @@ `npx skills add FairladyZ625/coding-agent-harness --skill coding-agent-harness`

@@ -18,4 +18,4 @@ # Agent 安装指南

用户不同意或未回复时,用 `npx --yes coding-agent-harness <command>` 运行同一条 CLI。
维护者在本源码仓调试时,可以在 `npm install` 或 `npm run build:runtime`
生成 `dist/` 后,把同一命令替换为 `node dist/harness.mjs`。
维护者调试当前源码 checkout 改动时,可以在仓库根目录把同一命令替换为
`node run-dist.mjs harness.mjs <command>`。

@@ -26,3 +26,3 @@ `harness init` 不会把 npm 包写进目标项目依赖;它只写 Harness 文档、模板和 registry。

`npx --yes coding-agent-harness ...`、用户批准后的全局 `harness`,或源码仓的
`node dist/harness.mjs`。
`node run-dist.mjs harness.mjs ...`。

@@ -29,0 +29,0 @@ `npx skills add FairladyZ625/coding-agent-harness --skill coding-agent-harness`

@@ -47,3 +47,3 @@ # Contributor Guide

| CLI/runtime | `npm run typecheck`, `npm run typecheck:guards`, `npm test`, `npm run check`, `git diff --check` |
| Templates or examples | `npm test`, `npm run build:runtime`, `node dist/harness.mjs check --profile target-project examples/minimal-project`, `git diff --check` |
| Templates or examples | `npm test`, `npm run build:runtime`, `node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project`, `git diff --check` |
| Dashboard | `npm test`, `npm run smoke:dashboard`, `git diff --check` |

@@ -62,3 +62,3 @@ | Package surface | `npm test`, `npm run pack:dry-run`, `git diff --check` |

npm run check
node dist/harness.mjs check --profile target-project examples/minimal-project
node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project
npm run pack:dry-run

@@ -65,0 +65,0 @@ git diff --check

@@ -46,3 +46,3 @@ # 贡献者指南

| CLI / runtime | `npm run typecheck`, `npm run typecheck:guards`, `npm test`, `npm run check`, `git diff --check` |
| 模板或示例 | `npm test`, `npm run build:runtime`, `node dist/harness.mjs check --profile target-project examples/minimal-project`, `git diff --check` |
| 模板或示例 | `npm test`, `npm run build:runtime`, `node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project`, `git diff --check` |
| Dashboard | `npm test`, `npm run smoke:dashboard`, `git diff --check` |

@@ -61,3 +61,3 @@ | Package surface | `npm test`, `npm run pack:dry-run`, `git diff --check` |

npm run check
node dist/harness.mjs check --profile target-project examples/minimal-project
node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project
npm run pack:dry-run

@@ -64,0 +64,0 @@ git diff --check

@@ -9,2 +9,10 @@ # Full Legacy Migration Subagent Strategy

## Path Terminology
This guide intentionally mentions old paths such as `docs/Harness-Ledger.md`,
`docs/09-PLANNING/**`, `docs/10-WALKTHROUGH/**`, `docs/11-REFERENCE/**`, and
legacy numbered directories. Treat them as legacy input evidence and scoped
rewrite targets during migration. The active v2 destination is the detected
Harness root, normally `coding-agent-harness/`, not root `docs/`.
## Definition of Done

@@ -11,0 +19,0 @@

@@ -9,2 +9,9 @@ # 完整旧 Harness 迁移 Subagent 策略

## 路径术语
本指南有意提到 `docs/Harness-Ledger.md`、`docs/09-PLANNING/**`、
`docs/10-WALKTHROUGH/**`、`docs/11-REFERENCE/**` 和旧编号目录。它们是迁移过程中的
legacy input evidence 和受限重写目标,不是当前 active v2 结构。active v2 目标位置是探测到的
Harness 根目录,通常是 `coding-agent-harness/`,不是根目录 `docs/`。
## 完成定义

@@ -11,0 +18,0 @@

@@ -22,2 +22,11 @@ # Legacy Harness Migration Agent Prompt

## Path Terminology
When this prompt names `docs/Harness-Ledger.md`, `docs/09-PLANNING/**`,
`docs/10-WALKTHROUGH/**`, `docs/11-REFERENCE/**`, `03-ARCHITECTURE`,
`04-DEVELOPMENT`, or `06-INTEGRATIONS`, those are legacy input paths. Read them
as old evidence sources that may need migration. They are not the active v2
runtime structure. New active state belongs under the detected Harness root,
normally `coding-agent-harness/`.
## Non-Negotiable Rules

@@ -87,3 +96,3 @@

First check whether the target environment has the `harness` command. If it does not, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Run that global install only after explicit approval. If the user does not approve or does not respond, use `npx --yes coding-agent-harness <command>` for Harness CLI calls. If you are debugging from the source checkout, run `npm install` or `npm run build:runtime` first, then replace `harness` with `node dist/harness.mjs`.
First check whether the target environment has the `harness` command. If it does not, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Run that global install only after explicit approval. If the user does not approve or does not respond, use `npx --yes coding-agent-harness <command>` for Harness CLI calls. If you are debugging current source checkout changes, replace `harness` with `node run-dist.mjs harness.mjs` from the repository root.

@@ -90,0 +99,0 @@ After user confirmation, run or reuse:

@@ -22,2 +22,10 @@ # 旧 Harness 迁移 Agent Prompt

## 路径术语
本文出现的 `docs/Harness-Ledger.md`、`docs/09-PLANNING/**`、
`docs/10-WALKTHROUGH/**`、`docs/11-REFERENCE/**`、`03-ARCHITECTURE`、
`04-DEVELOPMENT` 或 `06-INTEGRATIONS` 都是旧项目输入路径。它们表示需要读取或迁移的历史证据,
不是 v2 active runtime 结构。新的 active state 应写入探测到的 Harness 根目录,
通常是 `coding-agent-harness/`。
## 不可违反的规则

@@ -87,3 +95,3 @@

先检查目标环境是否有 `harness` 命令。如果没有,不要静默全局安装。先询问用户是否允许运行 `npm install -g coding-agent-harness`。只有用户明确同意后才执行全局安装。用户不同意或未回复时,Harness CLI 都用 `npx --yes coding-agent-harness <command>` 临时执行。如果你在源码仓调试,先运行 `npm install` 或 `npm run build:runtime` 生成 `dist/`,再把 `harness` 替换为 `node dist/harness.mjs`。
先检查目标环境是否有 `harness` 命令。如果没有,不要静默全局安装。先询问用户是否允许运行 `npm install -g coding-agent-harness`。只有用户明确同意后才执行全局安装。用户不同意或未回复时,Harness CLI 都用 `npx --yes coding-agent-harness <command>` 临时执行。如果你在调试当前源码 checkout 改动,在仓库根目录把 `harness` 替换为 `node run-dist.mjs harness.mjs`。

@@ -90,0 +98,0 @@ 用户确认迁移模式后再运行或复用:

@@ -14,3 +14,3 @@ # Legacy Harness Smooth Migration Playbook

This guide assumes the installed `harness` command. The executing agent must first check `command -v harness`. If the target environment does not have `harness`, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Install globally only after explicit approval. If the user does not approve or does not respond, run the same CLI with `npx --yes coding-agent-harness <command>`. Maintainers debugging from the source checkout can replace it with `node dist/harness.mjs` after `npm install` or `npm run build:runtime` has generated `dist/`.
This guide assumes the installed `harness` command. The executing agent must first check `command -v harness`. If the target environment does not have `harness`, do not silently install globally. Ask the user whether `npm install -g coding-agent-harness` is allowed. Install globally only after explicit approval. If the user does not approve or does not respond, run the same CLI with `npx --yes coding-agent-harness <command>`. Maintainers debugging current source checkout changes can replace it with `node run-dist.mjs harness.mjs <command>` from the repository root.

@@ -50,2 +50,10 @@ ## Target Path And v2 Structure

Legacy path references later in this guide, such as `docs/Harness-Ledger.md`,
`docs/09-PLANNING/**`, `docs/10-WALKTHROUGH/**`, `docs/11-REFERENCE/**`,
`03-ARCHITECTURE`, `04-DEVELOPMENT`, and `06-INTEGRATIONS`, are legacy input
paths. They name old evidence to read or migrate. They are not the active v2
runtime layout. The v2 destination is the declared Harness root, usually
`coding-agent-harness/`, with generated views under
`coding-agent-harness/governance/generated/`.
## Migration Principles

@@ -52,0 +60,0 @@

@@ -17,5 +17,5 @@ # 旧 Harness 平滑迁移 Playbook

`npm install -g coding-agent-harness`。用户明确同意后才能安装。用户不同意或未回复时,
用 `npx --yes coding-agent-harness <command>` 运行同一条 CLI。维护者在本源码仓调试时,
可以在 `npm install` 或 `npm run build:runtime` 生成 `dist/` 后,把同一命令替换为
`node dist/harness.mjs`。
用 `npx --yes coding-agent-harness <command>` 运行同一条 CLI。维护者调试当前源码
checkout 改动时,可以在仓库根目录把同一命令替换为
`node run-dist.mjs harness.mjs <command>`。

@@ -53,2 +53,8 @@ ## 目标路径和 v2 结构

本文后续出现的 `docs/Harness-Ledger.md`、`docs/09-PLANNING/**`、
`docs/10-WALKTHROUGH/**`、`docs/11-REFERENCE/**`、`03-ARCHITECTURE`、
`04-DEVELOPMENT` 和 `06-INTEGRATIONS` 都是旧项目输入路径。它们表示需要读取或迁移的历史证据,
不是 v2 active runtime 布局。v2 目标位置是已声明的 Harness 根目录,通常是
`coding-agent-harness/`,生成视图位于 `coding-agent-harness/governance/generated/`。
## 迁移原则

@@ -55,0 +61,0 @@

@@ -32,8 +32,13 @@ # Task State Machine And Lifecycle Queues

review_submitted --> human_confirmed: Dashboard workbench confirmation
human_confirmed --> finalized: task-complete + closeout
human_confirmed --> finalized: task-complete + post-confirmation finalization
finalized --> [*]
```
`task-review` means the agent submitted a review packet. It does not mean human approval. Dashboard workbench human confirmation is the human confirmation gate and writes its audit fields to `INDEX.md`. `task-complete` / closeout is not a substitute for review confirmation.
`task-review` means the agent submitted a review packet. It does not mean human approval. Dashboard workbench human confirmation is the human confirmation gate and writes its audit fields to `INDEX.md`. `task-complete` / post-confirmation finalization is not a substitute for review confirmation.
This guide separates two easily-confused closeout actions:
- Agent-owned closeout packet: the material an agent prepares before Human Review, including evidence, `review.md`, `walkthrough.md`, lesson decisions, and residual risk.
- Post-confirmation finalization: the `task-complete` / final lifecycle transition that is allowed only after Human Review Confirmation.
## Phase Kind Map

@@ -47,3 +52,3 @@

| `execution` | Implementation, documentation, and verification slices. | yes | `harness task-phase <task-id> <phase-id> --state done --completion 100 --evidence present` |
| `gate` | Agent review submission, human confirmation, lesson routing, walkthrough, and closeout. | no | `harness task-review`, Dashboard workbench human confirmation, or `harness task-complete` |
| `gate` | Agent review submission, human confirmation, lesson routing, the pre-review closeout packet, and post-confirmation finalization. | no | `harness task-review`, Dashboard workbench human confirmation, or `harness task-complete` |

@@ -100,4 +105,4 @@ Older phase tables without `Kind` remain valid and are treated as `execution`.

| `task-review` was submitted, materials are ready, and `INDEX.md` does not show human confirmation | `review-submitted` | Truly waiting for human review. |
| `INDEX.md` shows human confirmation, but closeout / ledger / lessons are not fully closed | `confirmed-finalization-pending` | Accountability moved to the reviewer, but governance closeout remains. |
| `INDEX.md` shows human confirmation, and closeout / ledger / lesson routing are complete | `finalized` | Truly complete and traceable. |
| `INDEX.md` shows human confirmation and no pending Lesson debt remains | `finalized` | Human review is the task lifecycle endpoint; the task becomes read-only traceable history. |
| `INDEX.md` shows human confirmation, but a Lesson candidate still needs decision or sedimentation | `lesson-finalization-pending` | The task is human-confirmed; only independent Lesson follow-up remains, and it does not re-enter human review or a closeout-ready queue. |
| `task.state = blocked` without a review blocker | `active-blocked` | Execution is blocked. |

@@ -135,5 +140,3 @@ | `task.state = in_progress` | `active` | Work is active. |

Submitted -->|no| Out["not in human review queue"]
Submitted -->|yes + human confirmed| Confirmed{"finalization complete?"}
Confirmed -->|no| QConfirmed["Confirmed / Finalized"]
Confirmed -->|yes| QFinal["Confirmed / Finalized"]
Submitted -->|yes + human confirmed| QFinal["Confirmed / Finalized"]
Task --> Lessons{"lesson candidate needs decision or sedimentation?"}

@@ -149,6 +152,6 @@ Lessons -->|yes| QLessons["Lessons"]

| Lessons | Lesson candidate needs decision, task-local retention, rejection, dry-run promotion, or a sedimentation task. | human + agent | Decision is complete, or a traceable sedimentation task exists. |
| Confirmed / Finalized | Human-confirmed, or finalized and ready for read-only tracing. | coordinator | Closeout, ledger, and lesson routing are complete; then read-only. |
| Confirmed / Finalized | Human-confirmed, or finalized and ready for read-only tracing. | coordinator | Read-only by default; pending Lesson debt is tracked independently in Lessons. |
| Soft-deleted / Superseded | Task was soft-deleted, replaced, merged, or archived; abandoned / duplicate / requirement-change semantics are tombstone reasons. | coordinator | Read-only tracing; reopen only when needed. |
The Review queue only waits for human confirmation. Missing materials, blockers, lesson sedimentation, confirmed-but-not-finalized work, and historical superseded tasks must not masquerade as Review queue items.
The Review queue only waits for human confirmation. Missing materials, blockers, lesson sedimentation, human-confirmed tasks, and historical superseded tasks must not masquerade as Review queue items.

@@ -155,0 +158,0 @@ ## Global Table Boundary

@@ -32,8 +32,13 @@ # 任务状态机与生命周期队列

review_submitted --> human_confirmed: Dashboard workbench confirmation
human_confirmed --> finalized: task-complete + closeout
human_confirmed --> finalized: task-complete + post-confirmation finalization
finalized --> [*]
```
`task-review` 表示 Agent 提交审查材料包,不表示人工批准。Dashboard workbench 人工确认才表示人工确认门禁,并把审计字段写入 `INDEX.md`。`task-complete` / closeout 也不是 review confirmation 的替代品。
`task-review` 表示 Agent 提交审查材料包,不表示人工批准。Dashboard workbench 人工确认才表示人工确认门禁,并把审计字段写入 `INDEX.md`。`task-complete` / post-confirmation finalization 也不是 review confirmation 的替代品。
本指南把两个容易混淆的收口动作分开:
- Agent-owned closeout packet:Agent 在提交 Human Review 前准备的材料,包括 evidence、`review.md`、`walkthrough.md`、lesson decision 和 residual risk。
- Post-confirmation finalization:Human Review Confirmation 之后,系统允许执行的 `task-complete` / final lifecycle transition。
## 阶段类型地图

@@ -47,3 +52,3 @@

| `execution` | 实现、文档和验证切片。 | 是 | `harness task-phase <task-id> <phase-id> --state done --completion 100 --evidence present` |
| `gate` | Agent 提交审查、人工确认、lesson routing、walkthrough 和 closeout。 | 否 | `harness task-review`、Dashboard workbench 人工确认,或 `harness task-complete` |
| `gate` | Agent 提交审查、人工确认、lesson routing、Review 前 closeout packet,以及人工确认后的 finalization。 | 否 | `harness task-review`、Dashboard workbench 人工确认,或 `harness task-complete` |

@@ -100,4 +105,4 @@ 旧阶段表没有 `Kind` 也继续有效,默认按 `execution` 处理。

| 已执行 `task-review`,材料齐全,且 `INDEX.md` 尚未显示人工确认 | `review-submitted` | 真正等待人审。 |
| `INDEX.md` 已显示人工确认,但 closeout / ledger / lessons 仍未全部收口 | `confirmed-finalization-pending` | 责任已转移给确认人,但治理收口仍待完成。 |
| `INDEX.md` 已显示人工确认,且 closeout / ledger / lesson routing 完成 | `finalized` | 真正完成,可只读追溯。 |
| `INDEX.md` 已显示人工确认,且没有待处理 Lesson 债务 | `finalized` | 人审是任务生命周期终点;任务进入只读追溯。 |
| `INDEX.md` 已显示人工确认,但 Lesson candidate 仍需判定或沉淀 | `lesson-finalization-pending` | 任务已被人确认;只剩独立 Lesson follow-up,不再回到人审或 closeout-ready 队列。 |
| `task.state = blocked` 但没有 review blocker | `active-blocked` | 执行阻塞。 |

@@ -135,5 +140,3 @@ | `task.state = in_progress` | `active` | 执行中。 |

Submitted -->|no| Out["not in human review queue"]
Submitted -->|yes + human confirmed| Confirmed{"finalization complete?"}
Confirmed -->|no| QConfirmed["Confirmed / Finalized"]
Confirmed -->|yes| QFinal["Confirmed / Finalized"]
Submitted -->|yes + human confirmed| QFinal["Confirmed / Finalized"]
Task --> Lessons{"lesson candidate needs decision or sedimentation?"}

@@ -149,6 +152,6 @@ Lessons -->|yes| QLessons["Lessons"]

| Lessons | lesson candidate 需要判定、保留、拒绝、dry-run promotion 或创建沉淀任务。 | human + agent | 决策完成,或创建可追踪沉淀任务。 |
| Confirmed / Finalized | 已人工确认,或已结项需要只读追溯。 | coordinator | closeout、ledger、lesson routing 全部完成;之后只读。 |
| Confirmed / Finalized | 已人工确认,或已结项需要只读追溯。 | coordinator | 默认只读;若有 Lesson 债务,按 Lessons 队列独立追踪。 |
| Soft-deleted / Superseded | 任务被软删除、替代、合并、归档或废弃。 | coordinator | 只读追溯;必要时 reopen。 |
Review 队列只等人确认。缺材料、阻塞、lesson 沉淀、已确认待结项、历史替代任务都不应伪装成 Review 队列项。
Review 队列只等人确认。缺材料、阻塞、lesson 沉淀、已确认任务、历史替代任务都不应伪装成 Review 队列项。

@@ -155,0 +158,0 @@ ## 全局表边界

{
"name": "coding-agent-harness",
"version": "1.1.0",
"version": "1.1.1",
"description": "Document governance kernel for long-running coding agents.",

@@ -71,3 +71,4 @@ "type": "module",

"devDependencies": {
"@types/node": "24"
"@types/node": "24",
"effect": "3.21.2"
},

@@ -74,0 +75,0 @@ "engines": {

@@ -316,142 +316,2 @@ #!/usr/bin/env node

function taskRootsFromContext(targetRoot, paths) {
const roots = [];
const seen = new Set();
const addRoot = (kind, relativeRoot) => {
if (!relativeRoot) return;
const root = path.join(targetRoot, relativeRoot);
const key = path.resolve(root);
if (seen.has(key)) return;
seen.add(key);
roots.push({ kind, root, relativeRoot: toPosix(relativeRoot) });
};
addRoot("TASKS", paths.tasksRoot);
addRoot("MODULES", paths.modulesRoot);
addRoot("EXTERNAL", paths.externalRoot);
if (Array.isArray(paths.taskRoots)) {
for (const relativeRoot of paths.taskRoots) addRoot(inferTaskRootKind(relativeRoot, paths), relativeRoot);
}
return roots;
}
function inferTaskRootKind(relativeRoot, paths) {
const normalized = toPosix(relativeRoot);
if (normalized === toPosix(paths.tasksRoot || "")) return "TASKS";
if (normalized === toPosix(paths.modulesRoot || "")) return "MODULES";
if (normalized === toPosix(paths.externalRoot || "")) return "EXTERNAL";
return "EXTERNAL";
}
function collectTasks(roots) {
const tasks = [];
for (const rootInfo of roots) {
if (!fs.existsSync(rootInfo.root)) continue;
const taskPlans = walk(rootInfo.root).filter((file) => path.basename(file) === "task_plan.md");
for (const taskPlanPath of taskPlans) {
const taskDir = path.dirname(taskPlanPath);
const identity = taskIdentity(rootInfo, taskDir);
if (!identity) continue;
const taskPlan = read(taskPlanPath);
const progress = read(path.join(taskDir, "progress.md"));
const review = read(path.join(taskDir, "review.md"));
const index = read(path.join(taskDir, "INDEX.md"));
const budget = parseBudget(taskPlan);
const state = parseState(progress);
const tombstone = parseTombstone(taskPlan);
tasks.push({
...identity,
title: titleFrom(taskPlan, identity.shortId),
preset: metadataLine(taskPlan, ["Task Preset", "Preset"]).toLowerCase(),
state,
deletionState: tombstone.state || "active",
queue: state === "blocked" ? "blocked" : "active",
budget,
hasOpenBlockingFindings: hasOpenBlockingReviewFinding(review),
materialsIncomplete: budget !== "simple" && hasIncompleteMaterials(taskDir),
reviewConfirmation: parseReviewConfirmation(index, identity.id),
evidenceSnippet: progress.split(/\r?\n/).find((line) => /\/Users\/|\/Volumes\/|file:\/\//.test(line)) || "",
tombstone,
});
}
}
return tasks;
}
function taskIdentity(rootInfo, taskDir) {
const relative = toPosix(path.relative(rootInfo.root, taskDir));
if (!relative || relative.startsWith("..")) return null;
const segments = relative.split("/").filter(Boolean);
const shortId = segments.at(-1) || path.basename(taskDir);
const date = shortId.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || "";
if (rootInfo.kind === "TASKS") {
return {
id: `TASKS/${relative}`,
legacyId: shortId,
shortId,
date,
module: "",
rootKind: "TASKS",
};
}
if (rootInfo.kind === "MODULES") {
if (segments.length < 3 || segments[1] !== "tasks") return null;
const moduleKey = segments[0];
const taskRelative = segments.slice(2).join("/");
return {
id: `MODULES/${moduleKey}/${taskRelative}`,
legacyId: shortId,
shortId,
date,
module: moduleKey,
rootKind: "MODULES",
};
}
return {
id: `EXTERNAL/${toPosix(path.basename(rootInfo.root))}/${relative}`,
legacyId: shortId,
shortId,
date,
module: "",
rootKind: "EXTERNAL",
};
}
function toPosix(value) {
return String(value || "").split(path.sep).join("/");
}
function walk(root) {
const files = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const full = path.join(root, entry.name);
if (entry.isDirectory()) files.push(...walk(full));
else if (entry.isFile()) files.push(full);
}
return files;
}
function read(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return "";
}
}
function titleFrom(content, fallback) {
const match = String(content || "").match(/^#\s+(.+)$/m);
return match ? match[1].trim() : fallback;
}
function metadataLine(content, labels) {
const escaped = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
const match = String(content || "").match(new RegExp(`^(?:${escaped})\\s*[::]\\s*([^\\n]+)`, "im"));
return match ? match[1].replace(/`/g, "").trim() : "";
}
function parseState(progress) {
const match = String(progress || "").match(/^##\s*(?:Current Status|Status|状态)\s*[::]?\s*(?:\n\s*)?([^\n]+)/im);
return normalizeState(match ? match[1] : "");
}
function normalizeState(value) {

@@ -462,94 +322,2 @@ const raw = String(value || "").trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");

function parseBudget(taskPlan) {
const match = String(taskPlan || "").match(/Selected budget\s*[::]\s*([A-Za-z0-9_-]+)/i);
return match ? match[1].trim().toLowerCase() : "standard";
}
function hasOpenBlockingReviewFinding(review) {
const lines = String(review || "").split(/\r?\n/);
for (const line of lines) {
const row = line.trim();
if (!row.startsWith("|") || /---/.test(row) || /severity|finding/i.test(row)) continue;
const cells = row.slice(1, -1).split("|").map((cell) => cell.trim().toLowerCase());
const hasBlockingSeverity = cells.some((cell) => /^p[0-2]\b/.test(cell));
const openOrBlocking = cells.some((cell) => /^(yes|true|open|是|开放)$/.test(cell));
if (hasBlockingSeverity && openOrBlocking) return true;
}
return /^Open Blocking Findings\s*[::]\s*(yes|true|open|是)/im.test(review);
}
function hasIncompleteMaterials(taskDir) {
for (const fileName of ["task_plan.md", "progress.md", "review.md", "INDEX.md"]) {
const content = read(path.join(taskDir, fileName));
if (/Materials\s*(?:Status|Ready)\s*[::]\s*(incomplete|missing|no|false)/im.test(content)) return true;
}
return false;
}
function parseTombstone(content) {
const match = String(content || "").match(/^##\s*(?:Task Tombstone|任务墓碑)\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/im);
if (!match) return { state: "active", fields: {} };
const fields = {};
for (const line of match[1].split(/\r?\n/)) {
const row = line.trim();
if (!row.startsWith("|") || /---/.test(row) || /Field\s*\|\s*Value/i.test(row)) continue;
const cells = row.slice(1, -1).split("|").map((cell) => cell.trim().toLowerCase());
if (cells.length >= 2) fields[cells[0]] = cells.slice(1).join("|").trim();
}
return { state: fields.state || "soft-deleted", fields };
}
function parseReviewConfirmation(content, taskId) {
const fields = markdownFieldsFromBlock(content, /^##\s*(?:Task Audit Metadata|任务审计元数据)\s*$/im);
if (fields.size === 0) return { confirmed: false, missingFields: ["Task Audit Metadata"] };
if (normalizeToken(fields.get("human review status")) !== "confirmed") return { confirmed: false, missingFields: [] };
const required = ["confirmation id", "confirmed at", "reviewer", "reviewer email", "confirm text", "evidence checked", "review commit sha", "audit status"];
const missing = required.filter((field) => !isConcreteAuditField(fields.get(field)));
const confirmText = fields.get("confirm text") || "";
if (isConcreteAuditField(confirmText) && !taskKeysMatch(confirmText, taskId)) missing.push("confirm text match");
const auditStatus = fields.get("audit status") || "";
if (isConcreteAuditField(auditStatus) && normalizeToken(auditStatus) !== "committed") missing.push("audit status committed");
const commitSha = fields.get("review commit sha") || "";
if (isConcreteAuditField(commitSha) && !/^[0-9a-f]{7,40}$/i.test(commitSha)) missing.push("review commit sha valid");
return {
confirmed: missing.length === 0,
missingFields: missing,
confirmationId: fields.get("confirmation id") || "",
confirmedAt: fields.get("confirmed at") || "",
reviewer: fields.get("reviewer") || "",
reviewerEmail: fields.get("reviewer email") || "",
confirmText,
evidenceChecked: fields.get("evidence checked") || "",
commitSha,
};
}
function markdownFieldsFromBlock(content, headingPattern) {
const match = String(content || "").match(new RegExp(`${headingPattern.source}([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, headingPattern.flags));
const fields = new Map();
if (!match) return fields;
for (const line of match[1].split(/\r?\n/)) {
const row = line.trim();
if (!row.startsWith("|") || /---/.test(row) || /Field\s*\|\s*Value/i.test(row)) continue;
const cells = row.slice(1, -1).split("|").map((cell) => cell.trim());
if (cells.length >= 2) fields.set(cells[0].toLowerCase(), cells.slice(1).join("|").trim());
}
return fields;
}
function isConcreteAuditField(value) {
const raw = String(value || "").replace(/`/g, "").trim();
return Boolean(raw) && !/^(n\/a|na|none|pending(?:[-_ ].*)?|todo|tbd|\[.*\]|-|—|–|不适用|无|待定|\{\})$/i.test(raw) && !/\{\{[^}]+\}\}/.test(raw);
}
function normalizeToken(value) {
return String(value || "").trim().toLowerCase().replaceAll("_", "-").replace(/\s+/g, "-");
}
function taskKeysMatch(candidate, expected) {
const left = String(candidate || "").replace(/`/g, "").trim();
const right = String(expected || "").replace(/`/g, "").trim();
return left === right || right.endsWith(`/${left}`);
}
function safeRelease(value) {

@@ -556,0 +324,0 @@ const release = String(value || "").trim();

@@ -364,3 +364,3 @@ # Coding Agent Harness

Run the checks that match the change. For docs-only changes, run git diff --check. For root package changes, run npm install, npm run build:runtime, npm run typecheck, npm run typecheck:guards, npm test, npm run smoke:dashboard, npm run check, node dist/harness.mjs check --profile target-project examples/minimal-project, npm run pack:dry-run, and git diff --check as relevant. If the change touches harness-gui, also run cd harness-gui && npm ci && npm run typecheck && npm test && npm run build. The source repository ignores `dist/`; npm install, prepare, prepack, and the root npm scripts regenerate it when needed.
Run the checks that match the change. For docs-only changes, run git diff --check. For root package changes, run npm install, npm run build:runtime, npm run typecheck, npm run typecheck:guards, npm test, npm run smoke:dashboard, npm run check, node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project, npm run pack:dry-run, and git diff --check as relevant. If the change touches harness-gui, also run cd harness-gui && npm ci && npm run typecheck && npm test && npm run build. The source repository ignores `dist/`; npm install, prepare, prepack, and the root npm scripts regenerate it when needed. Use `npx --yes coding-agent-harness ...` only when validating the published npm package behavior, not current checkout changes.

@@ -367,0 +367,0 @@ When done, summarize what changed, list verification results, call out any skipped checks with reasons, and prepare the PR using the repository template.

@@ -342,3 +342,3 @@ # Coding Agent Harness

根据改动范围运行检查。仅文档改动至少运行 git diff --check。根包相关改动按需运行 npm install、npm run build:runtime、npm run typecheck、npm run typecheck:guards、npm test、npm run smoke:dashboard、npm run check、node dist/harness.mjs check --profile target-project examples/minimal-project、npm run pack:dry-run 和 git diff --check。如果改到 harness-gui,还要运行 cd harness-gui && npm ci && npm run typecheck && npm test && npm run build。源码仓不跟踪 `dist/`;npm install、prepare、prepack 和根仓 npm scripts 会按需重新生成。
根据改动范围运行检查。仅文档改动至少运行 git diff --check。根包相关改动按需运行 npm install、npm run build:runtime、npm run typecheck、npm run typecheck:guards、npm test、npm run smoke:dashboard、npm run check、node run-dist.mjs harness.mjs check --profile target-project examples/minimal-project、npm run pack:dry-run 和 git diff --check。如果改到 harness-gui,还要运行 cd harness-gui && npm ci && npm run typecheck && npm test && npm run build。源码仓不跟踪 `dist/`;npm install、prepare、prepack 和根仓 npm scripts 会按需重新生成。只有验证已发布 npm 包行为时,才使用 `npx --yes coding-agent-harness ...`,不要用它替代当前 checkout 自测。

@@ -345,0 +345,0 @@ 完成后,请总结改了什么,列出验证结果,说明任何未运行检查及原因,并按仓库 PR 模板准备 PR。

@@ -1,2 +0,26 @@

const bundle = window.__HARNESS_DASHBOARD__ || {};
const dashboardBundleSchemaVersion = "dashboard-bundle/v1";
let rawBundle = window.__HARNESS_DASHBOARD__ || {};
let bundle = normalizeDashboardBundle(rawBundle);
function normalizeDashboardBundle(nextRawBundle) {
const bundleSchemaCompatible = !nextRawBundle.schemaVersion || nextRawBundle.schemaVersion === dashboardBundleSchemaVersion;
return bundleSchemaCompatible ? nextRawBundle : {
schemaVersion: nextRawBundle.schemaVersion || "missing",
schemaError: `Unsupported dashboard bundle schema: ${nextRawBundle.schemaVersion || "missing"}`,
status: { tasks: [], summary: {}, checkState: { details: { warnings: [], failures: [] } } },
tables: { tables: [] },
documents: { documents: [] },
graph: { nodes: [], edges: [] },
modules: [],
moduleSummary: {},
adoption: { warnings: [], summary: {} },
presetCatalog: { presets: [], roots: [], summary: {} },
};
}
function setDashboardBundle(nextRawBundle) {
rawBundle = nextRawBundle || {};
window.__HARNESS_DASHBOARD__ = rawBundle;
bundle = normalizeDashboardBundle(rawBundle);
}
const defaultLocale = window.__HARNESS_LOCALE__ || ((navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en");

@@ -33,3 +57,3 @@ let locale = localStorage.getItem("harness.locale") || defaultLocale;

theme: localStorage.getItem("harness.theme") || "system",
taskLayout: localStorage.getItem("harness.taskLayout") || "list",
taskLayout: localStorage.getItem("harness.taskLayout") || "swimlane",
taskSortOrder: localStorage.getItem("harness.taskSortOrder") === "asc" ? "asc" : "desc",

@@ -39,2 +63,4 @@ runtime: { mode: "static", csrfToken: "", writableActions: [] },

runtimePoller: null,
runtimeRefreshInFlight: false,
runtimeRefreshError: "",
};

@@ -41,0 +67,0 @@

@@ -25,3 +25,4 @@ function t(key) {

function shell() {
return `<div class="visibility-shell">
return `<a class="skip-link" href="#main">${escapeHtml(t("skipToMain"))}</a>
<div class="visibility-shell">
<header class="hero">

@@ -32,3 +33,3 @@ <div class="hero-copy">

</div>
<div class="hero-actions">
<nav class="hero-actions" aria-label="${escapeAttr(t("primaryNavigation"))}">
${routeLink("#/", t("overview"), "overview")}

@@ -40,10 +41,12 @@ ${routeLink("#/tasks", t("taskIndex"), "tasks")}

${routeLink("#/presets", t("presetCatalog"), "presets")}
<button data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
<button data-theme-toggle>${themeLabel()}</button>
</div>
<button type="button" data-language-toggle>${locale === "zh" ? "EN" : "中文"}</button>
<button type="button" data-theme-toggle>${themeLabel()}</button>
</nav>
</header>
${runtimeModeBanner()}
${renderRoute()}
<main id="main" tabindex="-1">
${runtimeModeBanner()}
${renderRoute()}
</main>
<div id="drawer-overlay" class="drawer-overlay"></div>
<div id="task-drawer" class="task-drawer"></div>
<div id="task-drawer" class="task-drawer" aria-hidden="true" inert></div>
</div>`;

@@ -50,0 +53,0 @@ }

@@ -27,3 +27,3 @@ function overview() {

const visual = summary.visualMapCoverage || {};
const withBrief = tasks.filter((task) => task.briefSource === "standalone").length;
const withBrief = tasks.filter((task) => taskMaterialsView(task).briefReady === true).length;
return `<section class="status-card-group">

@@ -57,3 +57,3 @@ <div class="status-primary ${displayState}">

if (failures > 0) return t("resolveBlockers");
const missingBriefs = normalCycleTasks().filter((task) => task.briefSource !== "standalone").length;
const missingBriefs = normalCycleTasks().filter((task) => taskMaterialsView(task).briefReady === false).length;
if (missingBriefs > 0) return `${missingBriefs} ${t("missingBriefs")}`;

@@ -74,6 +74,13 @@ const warnings = bundle.status?.checkState?.warnings || 0;

if (total === 0) return "";
const active = tasks.filter((task) => isActiveTaskState(task.state)).length;
const done = tasks.filter((task) => !isActiveTaskState(task.state) && (task.state === "done" || task.completion === 100)).length;
const planned = Math.max(0, total - done - active);
const active = tasks.filter(taskIsCurrentlyActive).length;
const done = tasks.filter(taskCountsAsCompleted).length;
const queued = tasks.filter((task) => !taskIsCurrentlyActive(task) && !taskCountsAsCompleted(task) && taskIsNonActiveQueueWork(task)).length;
const planned = Math.max(0, total - done - active - queued);
const pct = (n) => total > 0 ? Math.round((n / total) * 100) : 0;
const progressText = t("taskProgressAria")
.replaceAll("{done}", String(done))
.replaceAll("{active}", String(active))
.replaceAll("{queued}", String(queued))
.replaceAll("{planned}", String(planned))
.replaceAll("{total}", String(total));
return `<section class="flow-panel">

@@ -88,6 +95,7 @@ <div class="section-head">

<div class="progress-bar-container">
<div class="progress-bar">
${done > 0 ? `<div class="progress-segment done" style="width:${pct(done)}%" title="${t("done")}: ${done}"></div>` : ""}
${active > 0 ? `<div class="progress-segment active" style="width:${pct(active)}%" title="${t("active")}: ${active}"></div>` : ""}
${planned > 0 ? `<div class="progress-segment planned" style="width:${pct(planned)}%" title="${t("planned")}: ${planned}"></div>` : ""}
<div class="progress-bar" role="progressbar" aria-label="${escapeAttr(t("projectProgress"))}" aria-valuemin="0" aria-valuemax="${total}" aria-valuenow="${done}" aria-valuetext="${escapeAttr(progressText)}">
${done > 0 ? `<div class="progress-segment done" style="width:${pct(done)}%" title="${t("done")}: ${done}" aria-hidden="true"></div>` : ""}
${active > 0 ? `<div class="progress-segment active" style="width:${pct(active)}%" title="${t("active")}: ${active}" aria-hidden="true"></div>` : ""}
${queued > 0 ? `<div class="progress-segment queued" style="width:${pct(queued)}%" title="${t("queued")}: ${queued}" aria-hidden="true"></div>` : ""}
${planned > 0 ? `<div class="progress-segment planned" style="width:${pct(planned)}%" title="${t("planned")}: ${planned}" aria-hidden="true"></div>` : ""}
</div>

@@ -97,2 +105,3 @@ <div class="progress-legend">

<span class="legend-item"><span class="legend-dot active"></span>${t("active")} ${active}</span>
<span class="legend-item"><span class="legend-dot queued"></span>${t("queued")} ${queued}</span>
<span class="legend-item"><span class="legend-dot planned"></span>${t("planned")} ${planned}</span>

@@ -192,3 +201,2 @@ </div>

<span class="subtle">${t("activeBriefCount").replace("{count}", tasks.length).replace("{order}", taskSortLabel())}</span>
<a href="#/tasks">${t("openTaskIndex")}</a>
</div>

@@ -204,14 +212,37 @@ </div>

const tasks = normalCycleTasks();
const active = tasks.filter((task) => isActiveTaskState(task.state) || ["planned", "not_started"].includes(task.state));
if (active.length > 0) return sortTasksByTime(active);
return sortTasksByTime(tasks.filter((task) => task.briefSource === "standalone"));
return sortTasksByTime(tasks.filter(taskIsCurrentlyActive));
}
function isActiveTaskState(state) {
return ["active", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(state);
return state === "active" || state === "in_progress";
}
function taskIsCurrentlyActive(task) {
const projection = taskLifecycleProjection(task);
const queues = taskQueueValues(task);
return String(projection.deletionState || "active") === "active"
&& String(projection.state || "") === "in_progress"
&& String(projection.lifecycleState || "") === "active"
&& String(projection.closeoutStatus || "") !== "closed"
&& queues.includes("active")
&& clampCompletion(task.completion) < 100;
}
function taskCountsAsCompleted(task) {
const stateValue = taskStateValue(task);
const projection = taskLifecycleProjection(task);
return ["finalized", "done", "soft-deleted-superseded"].includes(stateValue)
|| String(projection.closeoutStatus || "") === "closed"
|| String(projection.lifecycleState || "") === "closed";
}
function taskIsNonActiveQueueWork(task) {
const stateValue = taskStateValue(task);
return ["missing-materials", "blocked", "review", "lessons"].includes(stateValue);
}
function taskBriefCard(task, { compact = true } = {}) {
const doc = taskDocument(task, "brief.md");
const summaryText = doc ? getBriefSummary(doc.content) : t("missingBriefExplain");
const stateValue = taskStateValue(task);
return `<article class="brief-card ${compact ? "compact" : ""}">

@@ -223,3 +254,3 @@ <div class="card-head">

</div>
${tag(task.state)}
${tag(stateValue)}
</div>

@@ -226,0 +257,0 @@ ${progressBar(task.completion)}

@@ -7,18 +7,24 @@ function clampCompletion(value) {

function stateToColorVar(state) {
const map = { in_progress: "--accent", review: "--accent-2", blocked: "--danger", done: "--ok", planned: "--muted", not_started: "--muted" };
const map = { active: "--accent", in_progress: "--accent", review: "--accent-2", "missing-materials": "--warn", lessons: "--accent-3", blocked: "--danger", confirmed: "--ok", "confirmed-finalization-pending": "--ok", finalized: "--ok", "soft-deleted-superseded": "--muted", done: "--ok", planned: "--muted", not_started: "--muted", unknown: "--muted" };
return map[state] || "--muted";
}
function taskLifecycleDisplay(task) {
const projection = taskLifecycleProjection(task);
return [projection.lifecycleState, projection.reviewStatus, projection.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
}
function taskStatRows(tasks) {
return [
{ state: "in_progress", label: t("statInProgress"), className: "in-progress" },
{ state: "review", label: t("statReview"), className: "review" },
{ state: "blocked", label: t("statBlocked"), className: "blocked" },
{ state: "done", label: t("statDone"), className: "done" },
{ state: "planned", label: label("planned"), className: "planned" },
{ state: "not_started", label: label("not_started"), className: "not-started" },
{ state: "unknown", label: label("unknown"), className: "unknown" },
{ state: "active", label: t("active"), className: "active" },
{ state: "missing-materials", label: t("queueMissingMaterials"), className: "missing-materials" },
{ state: "blocked", label: t("queueBlocked"), className: "blocked" },
{ state: "review", label: t("queueReview"), className: "review" },
{ state: "lessons", label: t("queueLessons"), className: "lessons" },
{ state: "finalized", label: label("finalized"), className: "finalized" },
{ state: "soft-deleted-superseded", label: t("queueSoftDeletedSuperseded"), className: "soft-deleted-superseded" },
{ state: "planned", label: t("planned"), className: "planned" },
].map((row) => ({
...row,
count: tasks.filter((task) => task.state === row.state).length,
count: tasks.filter((task) => taskStateValue(task) === row.state).length,
colorVar: stateToColorVar(row.state),

@@ -107,3 +113,3 @@ })).filter((row) => row.count > 0);

<select data-state-filter aria-label="${t("stateFilter")}">
${["all", "in_progress", "review", "blocked", "planned", "not_started", "done", "unknown"].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${label(value)}</option>`).join("")}
${["all", ...taskPrimaryQueueOrder].map((value) => `<option value="${value}" ${state.taskState === value ? "selected" : ""}>${value === "all" ? label(value) : taskQueueFilterLabel(value)}</option>`).join("")}
</select>

@@ -221,17 +227,21 @@ </div>

const completion = clampCompletion(task.completion);
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
const mapReady = !!taskDocument(task, "visual_map.md");
const materials = taskMaterialsView(task);
const briefReady = materials.briefReady === true;
const mapReady = materials.visualMapReady === true;
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
const moduleLabel = taskModuleLabel(task);
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
const lifecycle = taskLifecycleDisplay(task);
const stateValue = taskStateValue(task);
return `<article class="task-row-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)})">
return `<article class="task-row-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(stateValue)})">
<div class="row-accent-bar"></div>
<div class="row-main">
<strong>${escapeHtml(task.title)}</strong>
<div class="row-title-line">
<strong>${escapeHtml(task.title)}</strong>
${taskCopyButton(task, "row-copy")}
</div>
<span class="row-meta">${escapeHtml(task.id)} · ${escapeHtml(moduleLabel)}${lifecycle ? ` · ${escapeHtml(lifecycle)}` : ""}</span>
${taskCopyButton(task, "row-copy")}
</div>
<div class="row-status">${tag(task.state)}</div>
<div class="row-status">${tag(stateValue)}</div>
<div class="row-progress">

@@ -269,3 +279,3 @@ <div class="mini-progress-track"><div class="mini-progress-fill" style="width:${completion}%"></div></div>

${swimlane ? taskSwimlane(tasks) : visibleGroups.map(([group, groupTasks]) => taskGroup(group, groupTasks)).join("")}
${swimlane ? "" : `<section class="group-pager">
${swimlane || groupPageCount <= 1 ? "" : `<section class="group-pager">
<span>${t("showingGroups")} ${visibleGroups.length ? (groupPage - 1) * taskGroupsPerPage + 1 : 0}-${Math.min(groupPage * taskGroupsPerPage, orderedGroups.length)} / ${orderedGroups.length}</span>

@@ -319,9 +329,10 @@ ${pager("task-groups", groupPage, groupPageCount)}

if (state.taskGroupMode === "state") {
return groupBy(tasks, (task) => `state:${task.state || "unknown"}`);
return groupBy(tasks, (task) => `state:${taskStateValue(task)}`);
}
return groupBy(tasks, (task) => {
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)) return "active";
if (task.briefSource === "standalone") return "brief-ready";
const stateValue = taskStateValue(task);
if (taskPrimaryQueueOrder.includes(stateValue) && !["finalized", "soft-deleted-superseded"].includes(stateValue)) return stateValue;
if (taskMaterialsView(task).briefReady === true) return "brief-ready";
const match = task.shortId?.match(/^(\d{4}-\d{2})/);
return match ? `legacy:${match[1]}` : task.state || "unknown";
return match ? `legacy:${match[1]}` : stateValue || "unknown";
});

@@ -363,3 +374,3 @@ }

</div>
${pager("task", page, pageCount, group)}
${pageCount > 1 ? pager("task", page, pageCount, group) : ""}
</div>

@@ -376,8 +387,10 @@ </div>

const completion = clampCompletion(task.completion);
const stateColor = stateToColorVar(task.state);
const briefReady = task.briefSource === "standalone" || !!taskDocument(task, "brief.md");
const mapReady = !!taskDocument(task, "visual_map.md");
const stateValue = taskStateValue(task);
const stateColor = stateToColorVar(stateValue);
const materials = taskMaterialsView(task);
const briefReady = materials.briefReady === true;
const mapReady = materials.visualMapReady === true;
const briefLabel = briefReady ? t("briefReady") : t("briefMissing");
const mapLabel = mapReady ? t("mapReady") : t("mapMissing");
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
const lifecycle = taskLifecycleDisplay(task);

@@ -389,3 +402,3 @@ return `<article class="task-card" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateColor})">

${taskCopyButton(task, "compact")}
${tag(task.state)}
${tag(stateValue)}
</div>

@@ -420,2 +433,3 @@ </div>

if (group === "active") return t("activeCurrent");
if (["missing-materials", "blocked", "review", "lessons", "finalized", "soft-deleted-superseded"].includes(group)) return taskQueueFilterLabel(group);
if (group === "brief-ready") return t("briefReadyGroup");

@@ -432,6 +446,7 @@ if (group.startsWith("legacy:")) return `${t("legacyMonth")} ${group.slice("legacy:".length)}`;

return sortTasksByTime(normalCycleTasks().filter((task) => {
const stateMatch = state.taskState === "all" || task.state === state.taskState;
const stateValue = taskStateValue(task);
const stateMatch = state.taskState === "all" || stateValue === state.taskState;
if (!stateMatch) return false;
if (!query) return true;
return [task.id, task.shortId, task.title, task.module, task.inferredModule, task.classificationSource, task.classificationBucket, task.state].some((value) => String(value || "").toLowerCase().includes(query));
return [task.id, task.shortId, task.title, taskModuleKey(task), taskModuleLabel(task), stateValue, ...taskQueueValues(task)].some((value) => String(value || "").toLowerCase().includes(query));
}));

@@ -438,0 +453,0 @@ }

const swimlaneStageOrder = [
["planned", "swimlaneStagePlanned"],
["in_progress", "swimlaneStageInProgress"],
["evidence", "swimlaneStageEvidence"],
["review", "swimlaneStageReview"],
["confirmed", "swimlaneStageConfirmed"],
["closeout", "swimlaneStageCloseout"],
["blocked", "swimlaneStageBlocked"],
["active", "active"],
["planned", "planned"],
["missing-materials", "queueMissingMaterials"],
["blocked", "queueBlocked"],
["review", "queueReview"],
["lessons", "queueLessons"],
["finalized", "state_finalized"],
["soft-deleted-superseded", "queueSoftDeletedSuperseded"],
];

@@ -17,4 +18,6 @@ const swimlaneCellPageSize = 10;

.map((task) => {
const lane = taskModuleKey(task);
const stage = taskSwimlaneStage(task);
const swimlane = taskSwimlaneProjection(task);
if (!swimlane.rowKey || !swimlane.columnKey) return null;
const lane = swimlane.rowKey;
const stage = swimlane.columnKey;
return {

@@ -28,3 +31,4 @@ task,

};
});
})
.filter(Boolean);
const laneKeys = [...new Set(cards.map((card) => card.lane))].sort((left, right) => {

@@ -43,40 +47,33 @@ if (left === "legacy-unclassified") return 1;

function taskVisibleInSwimlane(task) {
const stateValue = String(task.state || "");
const closeout = String(task.closeoutStatus || "");
if (["done", "closed", "finalized"].includes(stateValue)) return false;
if (["closed", "finalized"].includes(closeout)) return false;
if (clampCompletion(task.completion) >= 100 && !["review", "blocked", "reopened", "current-evidence"].includes(stateValue)) return false;
return ["active", "planned", "not_started", "in_progress", "review", "blocked", "reopened", "current-evidence"].includes(stateValue)
|| ["ready-to-confirm", "needs-material", "review-blocked"].includes(String(task.reviewQueueState || ""))
|| ["agent-reviewed", "confirmed", "blocked-open-findings"].includes(String(task.reviewStatus || ""));
const swimlane = taskSwimlaneProjection(task);
if (typeof swimlane.visible === "boolean") return swimlane.visible;
return false;
}
function taskSwimlaneStage(task) {
const stateValue = String(task.state || "");
const review = String(task.reviewStatus || "");
const reviewQueue = String(task.reviewQueueState || "");
const closeout = String(task.closeoutStatus || "");
if (stateValue === "blocked" || review.includes("blocked") || reviewQueue.includes("blocked")) return "blocked";
if (review === "confirmed" && taskHasPendingLessonWork(task)) return "closeout";
if (review === "confirmed" && ["missing", "pending", "required", "closing"].includes(closeout)) return "closeout";
if (review === "confirmed") return "confirmed";
if (stateValue === "review" || reviewQueue === "ready-to-confirm" || (task.taskQueues || []).includes("review") || ["agent-reviewed", "in_review"].includes(review)) return "review";
if (["planned", "not_started"].includes(stateValue)) return "planned";
if (taskNeedsEvidence(task)) return "evidence";
if (["active", "in_progress", "reopened", "current-evidence"].includes(stateValue)) return "in_progress";
return "planned";
const swimlane = taskSwimlaneProjection(task);
if (swimlane.columnKey) return swimlane.columnKey;
return "";
}
function taskSwimlaneProjection(task) {
const view = taskDashboardTaskView(task);
return view?.swimlane && typeof view.swimlane === "object" ? view.swimlane : {};
}
function taskNeedsEvidence(task) {
if (["missing", "legacy-only"].includes(String(task.visualMapStatus || ""))) return true;
if (task.briefSource && task.briefSource !== "standalone") return true;
return (task.phases || []).some((phase) => ["missing", "partial"].includes(String(phase.evidenceStatus || "")));
const view = taskDashboardTaskView(task);
if (typeof view.needsEvidence === "boolean") return view.needsEvidence;
return false;
}
function taskSwimlaneReason(task) {
const reasons = Array.isArray(task.queueReasons) ? task.queueReasons.filter(Boolean) : [];
if (reasons.length) return reasons[0];
const view = taskDashboardTaskView(task);
if (view.reasonMessage) return view.reasonMessage;
if (view.reasonCode === "needs-evidence") return t("swimlaneNeedsEvidence");
if (view.reasonCode === "ready-to-confirm") return t("swimlaneReadyToConfirm");
if (view.reasonCode === "needs-closeout") return t("swimlaneNeedsCloseout");
const reasons = taskQueueReasonSummaries(task);
if (reasons.length) return reasons[0].message || reasons[0].code || reasons[0].queue || "";
if (taskNeedsEvidence(task)) return t("swimlaneNeedsEvidence");
if (task.reviewQueueState === "ready-to-confirm") return t("swimlaneReadyToConfirm");
if (task.closeoutStatus === "missing") return t("swimlaneNeedsCloseout");
return "";

@@ -264,3 +261,3 @@ }

const completion = clampCompletion(task.completion);
return `<article class="swimlane-card ${escapeAttr(card.stage)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(task.state)}); --task-progress: ${completion}%">
return `<article class="swimlane-card ${escapeAttr(card.stage)}" data-open-drawer="${escapeAttr(task.id)}" style="--row-accent: var(${stateToColorVar(taskStateValue(task))}); --task-progress: ${completion}%">
<span class="swimlane-status-dot" aria-hidden="true"></span>

@@ -267,0 +264,0 @@ <strong>${escapeHtml(task.title)}</strong>

@@ -0,1 +1,51 @@

function taskLifecycleProjection(task) {
return task?.taskLifecycleProjection || task?.semanticProjection?.taskLifecycleProjection || {};
}
function taskReviewProjection(task) {
return task?.reviewWorkbenchQueueView || task?.semanticProjection?.reviewWorkbenchQueueView || {};
}
const taskPrimaryQueueOrder = ["blocked", "missing-materials", "review", "lessons", "finalized", "soft-deleted-superseded", "active", "planned"];
function taskQueueFilterLabel(queue) {
const labels = {
active: t("active"),
"missing-materials": t("queueMissingMaterials"),
blocked: t("queueBlocked"),
review: t("queueReview"),
lessons: t("queueLessons"),
finalized: label("finalized"),
"soft-deleted-superseded": t("queueSoftDeletedSuperseded"),
planned: t("planned"),
};
return labels[queue] || label(queue);
}
function taskPrimaryQueueValue(task) {
const reviewProjection = taskReviewProjection(task);
const lifecycleProjection = taskLifecycleProjection(task);
if (reviewProjection.primaryQueue) return reviewProjection.primaryQueue;
const queues = Array.isArray(reviewProjection.queues)
? reviewProjection.queues
: Array.isArray(lifecycleProjection.taskQueues)
? lifecycleProjection.taskQueues
: [];
return taskPrimaryQueueOrder.find((queue) => queues.includes(queue)) || queues[0] || "unknown";
}
function taskStateValue(task) {
return taskPrimaryQueueValue(task);
}
function taskRawStateValue(task) {
const projection = taskLifecycleProjection(task);
return projection.state || task?.state || "unknown";
}
function taskQueueValues(task) {
const projection = taskLifecycleProjection(task);
return Array.isArray(projection.taskQueues) ? projection.taskQueues : [];
}
function taskDetail(route) {

@@ -34,14 +84,16 @@ const taskId = route.id;

function taskStateSummary(task) {
const lifecycle = taskLifecycleProjection(task);
const queues = Array.isArray(lifecycle.taskQueues) ? lifecycle.taskQueues : [];
return `<section class="task-state-summary">
<div>
<span>${t("legacyState")}</span>
${tag(task.state)}
${tag(taskRawStateValue(task))}
</div>
<div>
<span>${t("lifecycleState")}</span>
${tag(task.lifecycleState || "unknown")}
${tag(lifecycle.lifecycleState || "unknown")}
</div>
<div>
<span>${t("reviewStatus")}</span>
${tag(task.reviewStatus || "missing")}
${tag(lifecycle.reviewStatus || "missing")}
</div>

@@ -54,7 +106,7 @@ <div>

<span>${t("closeoutStatus")}</span>
${tag(task.closeoutStatus || "missing")}
${tag(lifecycle.closeoutStatus || "missing")}
</div>
<div>
<span>${t("lifecycleQueues")}</span>
${(task.taskQueues || []).map(tag).join("") || tag("active")}
${queues.map(tag).join("") || tag("unknown")}
</div>

@@ -66,3 +118,3 @@ ${taskQueueReasonSummary(task)}

function taskQueueReasonSummary(task) {
const reasons = task.queueReasons || [];
const reasons = taskQueueReasonSummaries(task);
if (!reasons.length) return "";

@@ -77,2 +129,7 @@ return `<div class="task-queue-reasons">

function taskQueueReasonSummaries(task) {
const projection = taskReviewWorkbenchQueueView(task);
return Array.isArray(projection.reasonSummaries) ? projection.reasonSummaries.filter(Boolean) : [];
}
function phaseTimeline(task) {

@@ -172,4 +229,5 @@ const knownKinds = new Set(["init", "execution", "gate"]);

function taskDocumentPriority(task) {
const stateName = task?.state || "";
const lifecycle = task?.lifecycleState || "";
const projection = taskLifecycleProjection(task);
const stateName = projection.state || "";
const lifecycle = projection.lifecycleState || "";
if (stateName === "review" || ["in_review", "review-blocked"].includes(lifecycle)) {

@@ -232,7 +290,8 @@ return ["walkthrough", "lessonCandidates", "review", "findings", "visualMap", "progress", "brief", "taskPlan", "strategy", "longRunningContract", "legacyRoadmap", "references", "artifacts"];

if (!isTaskInReviewQueue(task)) return "";
const blocking = task.reviewStatus === "blocked-open-findings" || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
const confirmed = task.reviewStatus === "confirmed";
const lifecycle = taskLifecycleProjection(task);
const reviewView = taskReviewWorkbenchQueueView(task);
const blocking = reviewView.blocked === true || (task.risks || []).some((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease));
const confirmed = reviewView.confirmed === true || lifecycle.reviewStatus === "confirmed";
const readyForCloseout = taskReadyForCloseout(task);
const hasLessonWork = taskHasPendingLessonWork(task);
const candidateBlocked = task.budget !== "simple" && !task.lessonCandidateDecisionComplete;
const candidateStatus = task.lessonCandidateStatus || "missing";

@@ -273,5 +332,8 @@ if (mode !== "workspace") {

const missingWalkthrough = task.budget !== "simple" && !task.walkthroughPath;
const candidateBlocked = task.budget !== "simple" && !task.lessonCandidateDecisionComplete;
const projectionOwnsConfirmability = typeof reviewView.humanConfirmable === "boolean";
const queueBlocked = !taskCanBeHumanConfirmed(task);
const disabled = blocking || missingWalkthrough || candidateBlocked || queueBlocked;
const message = missingWalkthrough ? t("reviewWalkthroughRequired") : blocking ? t("reviewBlocked") : candidateBlocked ? t("reviewCandidateDecisionRequired") : queueBlocked ? t("reviewQueueRequired") : t("reviewWorkbenchReady");
const rawMaterialBlocked = projectionOwnsConfirmability ? false : missingWalkthrough || candidateBlocked;
const disabled = blocking || rawMaterialBlocked || queueBlocked;
const message = blocking ? t("reviewBlocked") : queueBlocked ? t("reviewQueueRequired") : !projectionOwnsConfirmability && missingWalkthrough ? t("reviewWalkthroughRequired") : !projectionOwnsConfirmability && candidateBlocked ? t("reviewCandidateDecisionRequired") : t("reviewWorkbenchReady");
return `<section class="side-panel review-actions">

@@ -295,27 +357,41 @@ <h3>${t("reviewActions")}</h3>

function isTaskInReviewQueue(task) {
return (task?.reviewQueueState || "not-in-queue") !== "not-in-queue";
const view = taskReviewWorkbenchQueueView(task);
if (typeof view.inQueue === "boolean") return view.inQueue;
return false;
}
function taskCanBeHumanConfirmed(task) {
return task?.reviewQueueState === "ready-to-confirm" && Array.isArray(task?.taskQueues) && task.taskQueues.includes("review");
const view = taskReviewWorkbenchQueueView(task);
if (typeof view.humanConfirmable === "boolean") return view.humanConfirmable;
return false;
}
function taskHasPendingLessonWork(task) {
const queues = Array.isArray(task?.taskQueues) ? task.taskQueues : [];
const candidates = Array.isArray(task?.lessonCandidateRows) ? task.lessonCandidateRows : [];
return queues.includes("lessons")
|| task?.lessonCandidateStatus === "needs-promotion"
|| task?.lessonCandidatePromotionState === "queued"
|| candidates.some((candidate) => ["ready-for-review", "needs-promotion"].includes(String(candidate?.status || "")));
const view = taskReviewWorkbenchQueueView(task);
if (typeof view.hasPendingLessonWork === "boolean") return view.hasPendingLessonWork;
return false;
}
function taskReadyForCloseout(task) {
if (!task || task.reviewStatus !== "confirmed" || task.closeoutStatus === "closed") return false;
if (taskHasPendingLessonWork(task)) return false;
return ["no-candidate-accepted", "promoted", "rejected"].includes(String(task.lessonCandidateStatus || ""));
const view = taskReviewWorkbenchQueueView(task);
if (typeof view.readyForCloseout === "boolean") return view.readyForCloseout;
return false;
}
function taskDashboardTaskView(task) {
return task?.dashboardTaskView || task?.semanticProjection?.dashboardTaskView || {};
}
function taskMaterialsView(task) {
const view = taskDashboardTaskView(task);
return view?.materials && typeof view.materials === "object" ? view.materials : {};
}
function taskReviewWorkbenchQueueView(task) {
return task?.reviewWorkbenchQueueView || task?.semanticProjection?.reviewWorkbenchQueueView || {};
}
function evidenceList(task) {
const evidence = task.evidence || [];
return `<section class="side-panel">
return `<section class="side-panel evidence-panel">
<h3>${t("evidence")}</h3>

@@ -322,0 +398,0 @@ ${evidence.map((item) => `<p><strong>${escapeHtml(item.type || "evidence")}</strong> ${escapeHtml(item.summary || "")}</p>`).join("") || `<p>${t("noEvidence")}</p>`}

@@ -20,2 +20,28 @@ function dashboardModules() {

function dashboardModuleView(module) {
return module?.dashboardModuleView || module?.moduleProjection || {};
}
function moduleSourceLabel(module) {
const view = dashboardModuleView(module);
if (view.sourceLabelKey) return t(view.sourceLabelKey);
if (module?.key === "base") return t("moduleSourceStructure");
return t("moduleSourceUnknown");
}
function moduleStatusLabel(module) {
const view = dashboardModuleView(module);
if (view.statusLabelKey) {
const translated = t(view.statusLabelKey);
if (translated !== view.statusLabelKey) return translated;
}
return label(view.statusKey || "unknown");
}
function moduleStatusTag(module) {
const view = dashboardModuleView(module);
const tone = view.statusTone || (/blocked/i.test(view.statusKey || "") ? "fail" : /planned|unknown/i.test(view.statusKey || "") ? "warn" : "pass");
return `<span class="tag ${escapeAttr(tone)}">${escapeHtml(moduleStatusLabel(module))}</span>`;
}
function taskModuleLabel(task) {

@@ -47,5 +73,5 @@ const key = taskModuleKey(task);

}
const module = moduleDefinition(key) || { key, title: key, source: "inferred" };
const module = moduleDefinition(key) || { key, title: key, source: "inferred", dashboardModuleView: { sourceLabelKey: "moduleSourceInferred", statusLabelKey: "state_unknown", statusKey: "unknown", statusTone: "warn" } };
const chips = [
module.status ? `${t("columnState")}: ${label(module.status)}` : "",
`${t("columnState")}: ${moduleStatusLabel(module)}`,
module.owner ? `${t("moduleOwner")}: ${module.owner}` : "",

@@ -57,3 +83,3 @@ module.currentStep ? `${t("moduleCurrentStep")}: ${module.currentStep}` : "",

return {
eyebrow: module.source === "registry" ? t("registeredModule") : t("inferredModule"),
eyebrow: moduleSourceLabel(module),
title: module.title || key,

@@ -74,5 +100,5 @@ summary: `${tasks.length} ${t("tasks")} · ${counts.active} ${t("active")} · ${counts.review} ${t("statReview")} · ${counts.blocked} ${t("statBlocked")}`,

return {
active: tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state)).length,
review: tasks.filter((task) => task.state === "review").length,
blocked: tasks.filter((task) => task.state === "blocked").length,
active: tasks.filter(taskIsCurrentlyActive).length,
review: tasks.filter((task) => taskStateValue(task) === "review").length,
blocked: tasks.filter((task) => taskStateValue(task) === "blocked").length,
risk: tasks.filter(uiDashboardTaskHasRisk).length,

@@ -105,4 +131,5 @@ };

...module,
counts: emptyUiModuleCounts(),
counts: { ...emptyUiModuleCounts(), ...(dashboardModuleView(module).counts || {}) },
tasks: [],
__countsAuthoritative: !!dashboardModuleView(module).counts,
}]));

@@ -117,3 +144,12 @@ for (const task of normalCycleTasks()) {

source: key === "base" ? "structure" : "inferred",
status: task.classificationSource || "inferred",
dashboardModuleView: {
key,
title: taskModuleDisplayLabel(key),
sourceKind: key === "base" ? "structure" : "inferred",
sourceLabelKey: key === "base" ? "moduleSourceStructure" : "moduleSourceInferred",
statusKey: "unknown",
statusLabelKey: "state_unknown",
statusTone: "warn",
counts: emptyUiModuleCounts(),
},
counts: emptyUiModuleCounts(),

@@ -140,6 +176,7 @@ tasks: [],

if (!module || !task) return;
const stateValue = String(task.state || "unknown");
const stateValue = taskStateValue(task);
if (!module.tasks.some((item) => item.id === task.id)) module.tasks.push(task);
if (module.__countsAuthoritative) return;
module.counts.total = (module.counts.total || 0) + 1;
if (["in_progress", "review", "blocked", "planned", "not_started"].includes(stateValue)) {
if (taskIsCurrentlyActive(task)) {
module.counts.active = (module.counts.active || 0) + 1;

@@ -151,3 +188,3 @@ }

}
if (task.briefSource && task.briefSource !== "standalone") {
if (taskMaterialsView(task).briefReady === false) {
module.counts.missingDocs = (module.counts.missingDocs || 0) + 1;

@@ -158,7 +195,8 @@ }

function uiDashboardTaskHasRisk(task) {
if (task.state === "blocked") return true;
if (String(task.reviewStatus || "").includes("blocked")) return true;
if (Array.isArray(task.materialIssues) && task.materialIssues.length > 0) return true;
if (Array.isArray(task.queueReasons) && task.queueReasons.length > 0) return true;
if (String(task.visualMapStatus || "") === "missing") return true;
const reviewView = taskReviewProjection(task);
if (reviewView.blocked === true || reviewView.needsMaterials === true) return true;
if (Array.isArray(reviewView.reasonCodes) && reviewView.reasonCodes.length > 0) return true;
if (taskStateValue(task) === "blocked") return true;
const materials = taskMaterialsView(task);
if (materials.evidenceReady === false) return true;
return false;

@@ -170,3 +208,3 @@ }

const risk = modules.reduce((sum, module) => sum + Number(module.counts?.risk || 0), 0);
const registered = modules.filter((module) => module.source === "registry").length;
const registered = modules.filter((module) => dashboardModuleView(module).sourceKind === "registry").length;
return `<section class="module-run-strip">

@@ -185,7 +223,7 @@ ${metric(t("moduleRegistered"), registered)}

<strong>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</strong>
<small>${escapeHtml(module.key)} · ${escapeHtml(module.source || "registry")}</small>
<small>${escapeHtml(module.key)} · ${escapeHtml(moduleSourceLabel(module))}</small>
</span>
<span class="module-list-counts">
<b>${Number(counts.active || 0)}</b>
${tag(module.status || "planned")}
${moduleStatusTag(module)}
</span>

@@ -197,4 +235,4 @@ </a>`;

const tasks = normalCycleTasks().filter((task) => taskModuleKey(task) === module.key);
const activeTasks = tasks.filter((task) => ["in_progress", "review", "blocked", "planned", "not_started"].includes(task.state));
const riskTasks = tasks.filter((task) => task.state === "blocked" || String(task.reviewStatus || "").includes("blocked") || String(task.visualMapStatus || "") === "missing");
const activeTasks = tasks.filter(taskIsCurrentlyActive);
const riskTasks = tasks.filter(uiDashboardTaskHasRisk);
const brief = findDocument(module.briefPath || `TARGET:coding-agent-harness/planning/modules/${module.key}/brief.md`);

@@ -205,7 +243,7 @@ const plan = findDocument(module.modulePlanPath || "");

<div>
<p class="eyebrow">${escapeHtml(module.key === "base" ? t("baseModuleEyebrow") : module.source === "registry" ? t("registeredModule") : t("inferredModule"))}</p>
<p class="eyebrow">${escapeHtml(module.key === "base" ? t("baseModuleEyebrow") : moduleSourceLabel(module))}</p>
<h2>${escapeHtml(module.key === "base" ? t("baseModule") : module.title || module.key)}</h2>
<p class="subtle">${escapeHtml(module.key)}${module.currentStep ? ` · ${escapeHtml(module.currentStep)}` : ""}</p>
</div>
${tag(module.status || "planned")}
${moduleStatusTag(module)}
</header>

@@ -263,7 +301,8 @@ <div class="module-chip-row">

function moduleTaskRow(task) {
const dotClass = /fail|blocked|open/i.test(task.state) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(task.state) ? "state-warn" : "state-pass";
const lifecycle = [task.lifecycleState, task.reviewStatus, task.closeoutStatus].filter(Boolean).map((item) => label(item)).join(" · ");
const stateValue = taskStateValue(task);
const dotClass = /fail|blocked|open/i.test(stateValue) ? "state-fail" : /warn|advice|planned|missing|unknown/i.test(stateValue) ? "state-warn" : "state-pass";
const lifecycle = taskLifecycleDisplay(task);
return `<a class="module-task-row" href="#/tasks/${encodeURIComponent(task.id)}" data-open-drawer="${escapeAttr(task.id)}">
<div class="module-task-left">
<i class="module-task-dot ${dotClass}" title="${escapeAttr(task.state)}"></i>
<i class="module-task-dot ${dotClass}" title="${escapeAttr(stateValue)}"></i>
<span class="module-task-title">${escapeHtml(task.title || task.id)}</span>

@@ -270,0 +309,0 @@ ${lifecycle ? `<small>${escapeHtml(lifecycle)}</small>` : ""}

@@ -10,3 +10,4 @@ function reviewQueue() {

const confirmableTasks = activeTab.id === "review" ? tasks.filter(taskCanBeHumanConfirmed) : [];
syncReviewBulkSelection(confirmableTasks);
const allConfirmableTasks = activeTab.id === "review" ? baseTasks.filter(taskCanBeHumanConfirmed) : [];
syncReviewBulkSelection(allConfirmableTasks);
if (activeTab.id === "lessons") syncLessonBulkSelection(lessonBulkActionableSelections());

@@ -56,3 +57,3 @@ else syncLessonBulkSelection([]);

</div>
<div class="review-queue-pager">
<div class="review-queue-pager" ${pageCount <= 1 ? "hidden" : ""}>
${pager("review", page, pageCount)}

@@ -122,3 +123,5 @@ </div>

function taskMatchesReviewTab(task, tab) {
const view = taskReviewWorkbenchQueueView(task);
const queues = reviewTaskQueues(task);
if (view.primaryQueue && (tab.queues || []).includes(view.primaryQueue)) return true;
return (tab.queues || []).some((queue) => queues.includes(queue));

@@ -128,7 +131,12 @@ }

function reviewTaskQueues(task) {
return Array.isArray(task?.taskQueues) ? task.taskQueues : Array.isArray(task?.queues) ? task.queues : [];
const view = taskReviewWorkbenchQueueView(task);
if (Array.isArray(view.queues)) return view.queues;
return [];
}
function reviewReasonOptions(tasks) {
return [...new Set(tasks.flatMap((task) => (task.queueReasons || []).map((reason) => reason.code || reason.queue || "").filter(Boolean)))].sort();
return [...new Set(tasks.flatMap((task) => {
const view = taskReviewWorkbenchQueueView(task);
return (Array.isArray(view.reasonCodes) ? view.reasonCodes : []).filter(Boolean);
}))].sort();
}

@@ -147,4 +155,8 @@

.filter((task) => {
if (reasonFilter !== "all" && !(task.queueReasons || []).some((reason) => (reason.code || reason.queue) === reasonFilter)) return false;
const view = taskReviewWorkbenchQueueView(task);
const reasonCodes = Array.isArray(view.reasonCodes) ? view.reasonCodes : [];
if (reasonFilter !== "all" && !reasonCodes.includes(reasonFilter)) return false;
if (!query) return true;
const lifecycle = taskLifecycleProjection(task);
const queues = reviewTaskQueues(task);
return [

@@ -156,8 +168,8 @@ task.id,

task.inferredModule,
task.state,
task.lifecycleState,
task.reviewStatus,
task.closeoutStatus,
...(task.taskQueues || []),
...(task.queueReasons || []).flatMap((reason) => [reason.code, reason.message, reason.sourcePath]),
lifecycle.state,
lifecycle.lifecycleState,
lifecycle.reviewStatus,
lifecycle.closeoutStatus,
...queues,
...reasonCodes,
].some((value) => String(value || "").toLowerCase().includes(query));

@@ -187,5 +199,7 @@ })

const severityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
const reasonRank = Math.min(...(task.queueReasons || []).map((reason) => severityRank[String(reason.severity || "").toUpperCase()] ?? 8), 8);
const reasonRank = Math.min(...taskQueueReasonSummaries(task).map((reason) => severityRank[String(reason.severity || "").toUpperCase()] ?? 8), 8);
const queueRank = { blocked: 0, "missing-materials": 1, review: 2, lessons: 3, confirmed: 4, finalized: 5, "soft-deleted-superseded": 6 };
const queues = reviewTaskQueues(task);
const view = taskReviewWorkbenchQueueView(task);
if (view.primaryQueue && queueRank[view.primaryQueue] !== undefined) return queueRank[view.primaryQueue];
const taskQueueRank = Math.min(...queues.map((queue) => queueRank[queue] ?? 7), 7);

@@ -196,3 +210,8 @@ return Math.min(reasonRank, taskQueueRank);

function reviewTruthyCount(tasks, key) {
return tasks.filter((task) => task[key] === true).length;
return tasks.filter((task) => {
const lifecycle = taskLifecycleProjection(task);
if (key === "materialsReady" && typeof lifecycle.materialsReady === "boolean") return lifecycle.materialsReady;
if (key === "reviewSubmitted" && typeof lifecycle.reviewSubmitted === "boolean") return lifecycle.reviewSubmitted;
return false;
}).length;
}

@@ -232,3 +251,4 @@

const openMaterial = (task.risks || []).filter((risk) => /^P[0-2]$/i.test(risk.severity || "") && (risk.open || risk.blocksRelease)).length;
const reasons = task.queueReasons || [];
const reasons = taskQueueReasonSummaries(task);
const lifecycle = taskLifecycleProjection(task);
const canCopyRepairPrompt = tab?.repair && String(task.repairPrompt || "").trim();

@@ -246,6 +266,6 @@ const lessonActions = tab?.id === "lessons" ? lessonCandidatePanel(task, { context: "card", limit: 2 }) : "";

</label>` : "";
return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(task.state)})">
return `<article class="task-card review-queue-card" style="--row-accent: var(${stateToColorVar(lifecycle.state || taskStateValue(task))})">
<div class="card-header">
<span class="card-id" title="${escapeAttr(task.id)}">${escapeHtml(displayId)}</span>
${tag(task.reviewStatus || "missing")}
${tag(lifecycle.reviewStatus || "missing")}
${reviewTaskQueues(task).map(tag).join("")}

@@ -256,7 +276,7 @@ ${bulkControl}

<div class="card-meta">
<span>${tag(task.lifecycleState || "unknown")}</span>
<span>${tag(task.closeoutStatus || "missing")}</span>
<span>${tag(lifecycle.lifecycleState || "unknown")}</span>
<span>${tag(lifecycle.closeoutStatus || "missing")}</span>
<span>${openMaterial} ${t("openFindings")}</span>
<span>${t("reviewSubmitted")}: ${task.reviewSubmitted === true ? t("yes") : t("no")}</span>
<span>${t("materialsReady")}: ${task.materialsReady === true ? t("yes") : t("no")}</span>
<span>${t("reviewSubmitted")}: ${lifecycle.reviewSubmitted === true ? t("yes") : t("no")}</span>
<span>${t("materialsReady")}: ${lifecycle.materialsReady === true ? t("yes") : t("no")}</span>
</div>

@@ -442,2 +462,3 @@ <p class="subtle">${escapeHtml(firstUsefulLine(task.summary || task.briefText || ""))}</p>

const findingsDoc = taskDocument(task, "findings.md");
const lifecycle = taskLifecycleProjection(task);
return `<main class="review-workspace">

@@ -452,4 +473,4 @@ <nav class="crumbs"><a href="#/review">${t("reviewQueue")}</a><span>/</span><span>${escapeHtml(task.id)}</span></nav>

<div class="review-hero-tags">
${tag(task.lifecycleState || "unknown")}
${tag(task.reviewStatus || "missing")}
${tag(lifecycle.lifecycleState || "unknown")}
${tag(lifecycle.reviewStatus || "missing")}
${tag(task.lessonCandidateStatus || "missing")}

@@ -456,0 +477,0 @@ </div>

@@ -96,25 +96,5 @@ function migrationPanel() {

function warningQueue() {
const adoptionWarnings = (bundle.adoption?.warnings || []).map((warning) => ({ ...warning }));
const existingBriefPaths = new Set(adoptionWarnings.filter((warning) => warning.type === "missing-brief").map((warning) => warning.affected));
const briefWarnings = (bundle.status?.tasks || [])
.filter((task) => task.briefSource !== "standalone")
.filter((task) => !existingBriefPaths.has(task.path))
.map((task, index) => ({
id: `VB-${String(index + 1).padStart(3, "0")}`,
category: "Visibility Layer",
type: "missing-brief",
scope: "task",
priority: (typeof isActiveTaskState === "function" && isActiveTaskState(task.state)) || ["planned", "not_started"].includes(task.state) ? "P2" : "P3",
phase: "active-task-contracts",
fixability: "guided",
status: "open",
confidence: task.state === "unknown" ? "medium" : "high",
severity: "advice",
title: t("visibilityBriefMissing"),
affected: task.path,
affectedPaths: [task.path],
requiredAction: t("addVisibilityBrief"),
detail: `${task.id} ${task.title}`,
}));
return [...adoptionWarnings, ...briefWarnings].sort(warningSort);
const projected = bundle.adoption?.warningProjection?.queue;
const warnings = Array.isArray(projected) ? projected : (bundle.adoption?.warnings || []);
return warnings.map((warning) => ({ ...warning })).sort(warningSort);
}

@@ -121,0 +101,0 @@

function taskDocument(task, fileName) {
const projected = task?.documentsByKey?.[fileName] || task?.documentProjection?.byKey?.[fileName];
if (projected) return projected;
if (fileName === "__walkthrough__" && task.walkthroughPath) return findDocument(task.walkthroughPath);

@@ -21,3 +23,3 @@ return findDocument(`${task.path}/${fileName}`);

const score = Math.max(0, Math.min(100, Number(value) || 0));
return `<div class="progress" aria-label="${score}%"><i style="width:${score}%"></i></div>`;
return `<div class="progress" role="progressbar" aria-label="${score}%" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${score}"><i style="width:${score}%" aria-hidden="true"></i></div>`;
}

@@ -24,0 +26,0 @@

@@ -21,3 +21,24 @@ window.setModulePage = function(moduleKey, page) {

function rerenderPreservingScroll() {
const scrollX = window.scrollX || document.documentElement?.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement?.scrollTop || document.body?.scrollTop || 0;
const reviewShellTop = document.querySelector(".review-queue-list-shell")?.scrollTop || 0;
app();
const restore = () => {
window.scrollTo?.(scrollX, scrollY);
const nextReviewShell = document.querySelector(".review-queue-list-shell");
if (nextReviewShell) nextReviewShell.scrollTop = reviewShellTop;
};
restore();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(restore);
}
function bind() {
if (typeof document.querySelector === "function") document.querySelector(".skip-link")?.addEventListener("click", (event) => {
const main = document.getElementById("main");
if (!main) return;
event.preventDefault();
main.focus({ preventScroll: true });
main.scrollIntoView({ block: "start" });
});
document.querySelectorAll("[data-search]").forEach((input) => input.addEventListener("input", () => {

@@ -152,3 +173,3 @@ state.query = input.value;

state.reviewBulkResult = null;
app();
rerenderPreservingScroll();
}));

@@ -164,3 +185,3 @@ document.querySelectorAll("[data-review-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {

state.reviewBulkResult = null;
app();
rerenderPreservingScroll();
}));

@@ -177,3 +198,3 @@ document.querySelectorAll("[data-review-bulk-clear]").forEach((button) => button.addEventListener("click", () => {

state.lessonBulkResult = null;
app();
rerenderPreservingScroll();
}));

@@ -188,3 +209,3 @@ document.querySelectorAll("[data-lesson-bulk-select-all]").forEach((input) => input.addEventListener("change", () => {

state.lessonBulkResult = null;
app();
rerenderPreservingScroll();
}));

@@ -264,3 +285,3 @@ document.querySelectorAll("[data-lesson-bulk-clear]").forEach((button) => button.addEventListener("click", () => {

if (state.runtime?.snapshotVersion && nextRuntime.snapshotVersion !== state.runtime.snapshotVersion) {
window.location.reload();
await refreshDashboardSnapshot(nextRuntime);
return;

@@ -276,2 +297,32 @@ }

async function refreshDashboardSnapshot(nextRuntime = null) {
if (state.runtimeRefreshInFlight) return;
state.runtimeRefreshInFlight = true;
try {
const response = await fetch(`/assets/dashboard-data.js?v=${Date.now()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`dashboard data ${response.status}`);
const script = await response.text();
const match = script.match(/^\s*window\.__HARNESS_DASHBOARD__\s*=\s*([\s\S]*?);\s*$/);
if (!match) throw new Error("dashboard data payload missing");
setDashboardBundle(JSON.parse(match[1]));
let refreshedRuntime = nextRuntime;
if (state.runtime?.autoRefresh) {
const runtimeResponse = await fetch("/api/runtime", { cache: "no-store" });
if (runtimeResponse.ok) refreshedRuntime = await runtimeResponse.json();
}
if (refreshedRuntime) state.runtime = refreshedRuntime;
app();
} catch (error) {
state.runtimeRefreshError = error?.message || String(error);
} finally {
state.runtimeRefreshInFlight = false;
}
}
function scheduleDashboardSnapshotRefresh(delay = 0) {
setTimeout(() => {
refreshDashboardSnapshot().catch(() => {});
}, delay);
}
async function completeReviewFromDashboard(taskId) {

@@ -308,3 +359,3 @@ const result = document.querySelector(`[data-review-result="${CSS.escape(taskId)}"]`);

if (result) result.textContent = t("reviewCompleteSuccess");
setTimeout(() => window.location.reload(), 500);
scheduleDashboardSnapshotRefresh(500);
} catch (error) {

@@ -334,3 +385,3 @@ if (result) result.textContent = `${t("reviewCompleteFailed")}: ${error.message}`;

if (result) result.textContent = t("taskCloseoutSuccess");
setTimeout(() => window.location.reload(), 500);
scheduleDashboardSnapshotRefresh(500);
} catch (error) {

@@ -393,3 +444,3 @@ if (result) result.textContent = `${t("taskCloseoutFailed")}: ${error.message}`;

app();
if ((payload.confirmed || 0) > 0) setTimeout(() => window.location.reload(), 1500);
if ((payload.confirmed || 0) > 0) scheduleDashboardSnapshotRefresh(1500);
} catch (error) {

@@ -421,3 +472,3 @@ state.reviewBulkResult = { ok: false, message: `${t("reviewCompleteFailed")}: ${dashboardActionErrorDetail(error, t("reviewCompleteFailed"))}` };

app();
if (["install", "seed", "uninstall"].includes(action)) setTimeout(() => window.location.reload(), 650);
if (["install", "seed", "uninstall"].includes(action)) scheduleDashboardSnapshotRefresh(650);
} catch (error) {

@@ -452,3 +503,3 @@ state.presetActionResult = {

</div>
<button class="btn-close" data-close-drawer>×</button>
<button class="btn-close" data-close-drawer aria-label="${escapeAttr(t("close"))}">×</button>
</div>

@@ -489,2 +540,4 @@ `;

drawer.innerHTML = renderDrawerContent(taskId);
drawer.removeAttribute("aria-hidden");
drawer.removeAttribute("inert");
drawer.classList.add("active");

@@ -648,3 +701,3 @@ overlay.classList.add("active");

app();
if ((payload.created || 0) > 0) setTimeout(() => window.location.reload(), 1500);
if ((payload.created || 0) > 0) scheduleDashboardSnapshotRefresh(1500);
} catch (error) {

@@ -704,3 +757,3 @@ state.lessonBulkResult = { ok: false, message: `${t("lessonTaskCreateFailed")}: ${error?.error || error?.message || String(error)}` };

<h2>${escapeHtml(lessonId)}</h2>
<button class="btn-close" data-close-drawer>×</button>
<button class="btn-close" data-close-drawer aria-label="${escapeAttr(t("close"))}">×</button>
</div>

@@ -720,3 +773,3 @@ <div class="task-drawer-body">

</div>
<button class="btn-close" data-close-drawer>×</button>
<button class="btn-close" data-close-drawer aria-label="${escapeAttr(t("close"))}">×</button>
</div>

@@ -750,2 +803,4 @@ `;

drawer.innerHTML = renderLessonDrawerContent(lessonId);
drawer.removeAttribute("aria-hidden");
drawer.removeAttribute("inert");
drawer.classList.add("active");

@@ -760,3 +815,10 @@ overlay.classList.add("active");

const overlay = document.getElementById("drawer-overlay");
if (drawer) drawer.classList.remove("active");
if (drawer) {
if (drawer.contains(document.activeElement)) {
document.getElementById("main")?.focus({ preventScroll: true });
}
drawer.classList.remove("active");
drawer.setAttribute("aria-hidden", "true");
drawer.setAttribute("inert", "");
}
if (overlay) overlay.classList.remove("active");

@@ -763,0 +825,0 @@ }

@@ -7,21 +7,24 @@ @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap');

/* Backgrounds & Surfaces */
--bg: #f3f4f6; /* Slate 100: Elegant silver-gray background base */
--bg: #f6f7f9; /* Soft neutral background for long-running dashboard work */
--paper: #ffffff; /* Pure White: Card surface */
--paper-2: #e5e7eb; /* Slate 200: Soft tab & hover tint */
--paper-2: #eef1f5; /* Low-stimulus hover and secondary surface */
/* Typography */
--ink: #111827; /* Slate 900: Deep obsidian text */
--muted: #4b5563; /* Slate 600: Secondary text */
--muted: #647081; /* Secondary text */
/* Borders & Dividers */
--line: #d1d5db; /* Slate 300: High-contrast thin border line */
--line: #c7ced8; /* Soft UI boundary; text contrast is handled by ink/muted */
/* Branding Accents */
--accent: #d97706; /* Amber 600: Rich golden amber for primary actions */
--accent-2: #ca8a04; /* Yellow 600: Deep warm gold for gradients */
--accent: #d97706; /* Amber 600: original warm orange theme */
--accent-2: #ca8a04; /* Yellow 600: original warm gold for gradients */
--accent-text: #92400e; /* Amber 800: AA text companion for the orange theme */
--accent-on: #111827; /* Dark text for orange/yellow filled controls */
--eyebrow: #92400e; /* Amber 800: Compact label text with AA contrast */
/* Semantic Statuses */
--danger: #e11d48; /* Rose 600: Blockers, failures, and errors */
--warn: #ea580c; /* Orange 600: Warnings and advice */
--ok: #16a34a; /* Green 600: Complete and verified */
--danger: #be123c; /* Rose 700: Blockers, failures, and errors */
--warn: #9a3412; /* Orange 800: Warnings and advice */
--ok: #166534; /* Green 800: Complete and verified */

@@ -48,16 +51,19 @@ /* Dimensions, Shadows, & Glows */

/* Backgrounds & Surfaces */
--bg: #121214; /* Dark Charcoal: Obsidian dark grey */
--paper: #1a1a1c; /* Elevated dark gray zinc surface */
--paper-2: #27272a; /* Zinc 800: Hover tint and active tab */
--bg: #151518; /* Low-stimulus charcoal */
--paper: #1d1d21; /* Elevated dark gray surface */
--paper-2: #2a2a30; /* Hover tint and active tab */
/* Typography */
--ink: #f4f4f5; /* Zinc 100: Soft white contrast text */
--muted: #9ca3af; /* Slate 400: Mid-tone tech gray */
--muted: #a4a7ae; /* Mid-tone text */
/* Borders & Dividers */
--line: #2b2b2e; /* Subdued dark border line */
--line: #494952; /* Soft dark UI boundary */
/* Branding Accents */
--accent: #fbbf24; /* Amber 400: Radiant warm amber */
--accent-2: #f59e0b; /* Amber 500: Deep glowing amber */
--accent: #f59e0b; /* Warm original amber */
--accent-2: #d97706; /* Deep orange companion */
--accent-text: #fbbf24; /* Amber text companion on dark surfaces */
--accent-on: #111827; /* Dark text for orange/yellow filled controls */
--eyebrow: #fbbf24; /* Amber 400: Compact label text with high dark contrast */

@@ -99,3 +105,3 @@ /* Semantic Statuses */

box-sizing: border-box;
outline-color: var(--accent);
outline-color: var(--accent-text);
}

@@ -120,3 +126,3 @@

a:hover {
color: var(--accent);
color: var(--accent-text);
}

@@ -183,3 +189,3 @@

.eyebrow {
color: var(--accent);
color: var(--eyebrow);
font-weight: 800;

@@ -212,2 +218,4 @@ font-size: 11px;

padding: 8px 16px;
min-width: 44px;
min-height: 44px;
font-size: 13px;

@@ -220,5 +228,42 @@ font-weight: 600;

.skip-link {
position: absolute;
left: 16px;
top: 10px;
z-index: 2000;
background: var(--accent);
color: var(--accent-on);
border-radius: 8px;
box-shadow: var(--shadow-hover);
font-size: 13px;
font-weight: 800;
padding: 10px 14px;
transform: translateY(-160%);
transition: transform 0.18s ease;
}
.skip-link:focus,
.skip-link:focus-visible {
transform: translateY(0);
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 72%, transparent);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
.hero-actions a.active {
background: var(--accent);
color: var(--paper);
color: var(--accent-on);
border-color: var(--accent);

@@ -358,3 +403,3 @@ }

line-height: 1;
color: var(--accent);
color: var(--accent-text);
}

@@ -77,2 +77,6 @@ /* Sections & Panel Layouts */

.progress-segment.queued {
background: color-mix(in srgb, var(--accent) 46%, var(--paper-2));
}
.progress-segment.planned {

@@ -107,2 +111,3 @@ background: var(--paper-2);

.legend-dot.active { background: var(--accent); }
.legend-dot.queued { background: color-mix(in srgb, var(--accent) 46%, var(--paper-2)); }
.legend-dot.planned { background: var(--paper-2); border: 1px solid var(--line); }

@@ -137,3 +142,3 @@

.runway-breakdown a:hover {
border-color: var(--accent);
border-color: var(--accent-text);
background: var(--paper-2);

@@ -140,0 +145,0 @@ transform: translateY(-2px);

@@ -33,3 +33,3 @@ /* Active Task Briefs */

box-shadow: var(--shadow-hover);
border-color: var(--accent);
border-color: var(--accent-text);
}

@@ -51,2 +51,5 @@

.copy-task-name {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);

@@ -56,4 +59,6 @@ background: var(--paper-2);

border-radius: 6px;
padding: 5px 8px;
font-size: 11px;
min-width: 44px;
min-height: 36px;
padding: 8px 14px;
font-size: 12px;
font-weight: 800;

@@ -66,3 +71,3 @@ line-height: 1.2;

.copy-task-name:hover {
color: var(--accent);
color: var(--accent-text);
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));

@@ -73,7 +78,7 @@ }

.copy-task-name.row-copy {
padding: 4px 7px;
min-height: 34px;
padding: 7px 12px;
}
.copy-task-name.row-copy {
margin-top: 8px;
width: fit-content;

@@ -100,3 +105,3 @@ }

.card-head a:hover {
color: var(--accent);
color: var(--accent-text);
}

@@ -302,3 +307,3 @@

.warning-toolbar select:focus {
border-color: var(--accent);
border-color: var(--accent-text);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);

@@ -305,0 +310,0 @@ outline: none;

@@ -39,3 +39,3 @@ /* === Task Index Layout Redesign (Cockpit Style C) === */

letter-spacing: 0.05em;
color: var(--accent);
color: var(--accent-text);
border-left: 3px solid var(--accent);

@@ -81,3 +81,3 @@ padding-left: 8px;

.sidebar-card select:focus {
border-color: var(--accent);
border-color: var(--accent-text);
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.15);

@@ -116,3 +116,3 @@ outline: none;

font-weight: 800;
color: var(--accent);
color: var(--accent-text);
letter-spacing: -0.02em;

@@ -193,11 +193,11 @@ line-height: 1;

.legend-item .badge.ready {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
border: 1px solid rgba(22, 163, 74, 0.15);
background: rgba(22, 101, 52, 0.08);
color: var(--ok);
border: 1px solid rgba(22, 101, 52, 0.22);
}
.legend-item .badge.map-ready {
background: rgba(217, 119, 6, 0.08);
color: var(--accent);
border: 1px solid rgba(217, 119, 6, 0.15);
background: rgba(146, 64, 14, 0.08);
color: var(--accent-text);
border: 1px solid rgba(146, 64, 14, 0.22);
}

@@ -242,3 +242,3 @@

background: var(--paper);
color: var(--accent);
color: var(--accent-text);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);

@@ -299,8 +299,6 @@ }

.stat-chip.in-progress .stat-value {
color: var(--accent);
}
.stat-chip.active .stat-value,
.stat-chip.in-progress .stat-value,
.stat-chip.review .stat-value {
color: var(--accent-2);
color: var(--accent-text);
}

@@ -423,2 +421,9 @@

.row-title-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.row-main strong {

@@ -429,2 +434,4 @@ font-size: 14px;

display: block;
min-width: 0;
flex: 1;
overflow: hidden;

@@ -641,5 +648,5 @@ text-overflow: ellipsis;

.task-row-card .badge.brief.ready {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
border: 1px solid rgba(22, 163, 74, 0.15);
background: rgba(22, 101, 52, 0.08);
color: var(--ok);
border: 1px solid rgba(22, 101, 52, 0.22);
}

@@ -656,5 +663,5 @@

.task-row-card .badge.map.ready {
background: rgba(217, 119, 6, 0.08);
color: var(--accent);
border: 1px solid rgba(217, 119, 6, 0.15);
background: rgba(146, 64, 14, 0.08);
color: var(--accent-text);
border: 1px solid rgba(146, 64, 14, 0.22);
}

@@ -756,3 +763,3 @@

.crumbs a:hover {
color: var(--accent);
color: var(--accent-text);
}

@@ -124,3 +124,3 @@ /* Read-only runtime swimlane heatmap */

.swimlane-mobile-module.active {
border-color: var(--accent);
border-color: var(--accent-text);
}

@@ -168,3 +168,3 @@

.swimlane-heat-cell.active {
border-color: var(--accent);
border-color: var(--accent-text);
transform: translateY(-1px);

@@ -293,3 +293,3 @@ }

.swimlane-card:hover {
border-color: var(--accent);
border-color: var(--accent-text);
transform: translateY(-1px);

@@ -356,3 +356,3 @@ }

.swimlane-stage-drilldown span {
color: var(--accent);
color: var(--accent-text);
font-size: 11px;

@@ -359,0 +359,0 @@ font-weight: 900;

@@ -46,3 +46,3 @@ .review-queue-stats {

.review-queue-tab.active {
border-color: var(--accent);
border-color: var(--accent-text);
background: color-mix(in srgb, var(--accent) 10%, var(--paper));

@@ -125,3 +125,3 @@ }

height: 16px;
accent-color: var(--accent);
accent-color: var(--accent-text);
}

@@ -226,2 +226,10 @@

.review-queue-summary + .side-panel > p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
font-weight: 650;
}
.review-queue-actions {

@@ -249,3 +257,3 @@ display: flex;

.review-queue-actions button:hover {
border-color: var(--accent);
border-color: var(--accent-text);
}

@@ -359,5 +367,17 @@

.lesson-candidate-action strong {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.25;
}
.lesson-candidate-action span {
font-size: 13px;
line-height: 1.35;
}
.lesson-candidate-action small {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}

@@ -455,2 +475,5 @@

margin: 0;
font-size: 12px;
line-height: 1.35;
font-weight: 800;
}

@@ -467,2 +490,26 @@

.review-tombstone-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.review-tombstone-summary span:not(.tag),
.review-tombstone-summary a {
min-width: 0;
overflow-wrap: anywhere;
}
.review-tombstone-summary a {
color: var(--ink);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.runtime-banner {

@@ -469,0 +516,0 @@ display: flex;

@@ -22,3 +22,3 @@ /* Detail view panels */

font-weight: 800;
color: var(--accent);
color: var(--accent-text);
line-height: 1;

@@ -201,3 +201,3 @@ }

margin-right: 8px;
color: var(--accent);
color: var(--accent-text);
transform: rotate(0deg);

@@ -247,2 +247,18 @@ transition: transform 0.2s ease;

.side-panel > p,
.evidence-panel p {
margin: 8px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
font-weight: 650;
overflow-wrap: anywhere;
}
.evidence-panel p strong {
color: var(--ink);
font-size: 12px;
font-weight: 850;
}
.side-panel a {

@@ -262,4 +278,4 @@ display: inline-flex;

background: var(--accent);
color: var(--paper);
border-color: var(--accent);
color: var(--ink);
border-color: var(--accent-text);
}

@@ -343,6 +359,10 @@

.module-list-item > span:first-child {
min-width: 0;
}
.module-list-item:hover,
.module-list-item.active {
background: var(--paper-2);
border-color: var(--accent);
border-color: var(--accent-text);
}

@@ -370,4 +390,11 @@

flex-shrink: 0;
max-width: 112px;
}
.module-list-counts .tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.module-list-counts b {

@@ -530,3 +557,3 @@ font-size: 18px;

.migration-grid > button:hover {
border-color: var(--accent);
border-color: var(--accent-text);
background: var(--paper);

@@ -656,3 +683,3 @@ transform: translateY(-2px);

background: var(--paper);
border-color: var(--accent);
border-color: var(--accent-text);
}

@@ -659,0 +686,0 @@

@@ -89,3 +89,3 @@ /* Preset Workbench */

.preset-source-tabs button.active {
border-color: var(--accent);
border-color: var(--accent-text);
background: var(--paper-2);

@@ -141,3 +141,3 @@ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);

.preset-card.active {
border-color: var(--accent);
border-color: var(--accent-text);
background: var(--paper-2);

@@ -239,3 +239,3 @@ box-shadow: inset 3px 0 0 var(--accent);

.preset-layer-row:hover strong {
color: var(--accent);
color: var(--accent-text);
}

@@ -385,3 +385,3 @@

.preset-layer-row.active {
border-color: var(--accent);
border-color: var(--accent-text);
background: var(--paper-2);

@@ -388,0 +388,0 @@ }

@@ -113,2 +113,3 @@ /* Mobile Responsiveness Rules */

transform: translateX(105%);
pointer-events: none;
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);

@@ -118,2 +119,3 @@ }

transform: translateX(0);
pointer-events: auto;
}

@@ -141,2 +143,4 @@ @media (max-width: 768px) {

.task-drawer-header button.btn-close {
min-width: 44px;
min-height: 44px;
background: none;

@@ -188,3 +192,3 @@ border: none;

font-weight: 850;
color: var(--accent);
color: var(--accent-text);
line-height: 1;

@@ -213,3 +217,8 @@ }

.btn-drawer-trigger {
padding: 6px 14px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 36px;
padding: 8px 14px;
background: var(--paper-2);

@@ -226,4 +235,4 @@ border: 1px solid var(--line);

background: var(--accent);
color: var(--paper);
border-color: var(--accent);
color: var(--ink);
border-color: var(--accent-text);
}

@@ -245,3 +254,3 @@

.module-task-row:hover {
border-color: var(--accent);
border-color: var(--accent-text);
transform: translateX(3px);

@@ -285,3 +294,3 @@ }

font-size: 12px;
color: var(--accent);
color: var(--accent-text);
font-weight: 700;

@@ -318,2 +327,4 @@ flex-shrink: 0;

.module-pager button {
min-width: 44px;
min-height: 44px;
background: var(--paper);

@@ -331,4 +342,4 @@ border: 1px solid var(--line);

background: var(--accent);
color: var(--paper);
border-color: var(--accent);
color: var(--ink);
border-color: var(--accent-text);
}

@@ -356,3 +367,3 @@ .module-pager button:disabled {

.lesson-panel .lesson:hover {
border-color: var(--accent);
border-color: var(--accent-text);
transform: translateX(3px);

@@ -435,3 +446,3 @@ }

font-weight: 800;
color: var(--accent);
color: var(--accent-text);
}

@@ -541,2 +552,4 @@

padding: 6px 10px;
min-width: 44px;
min-height: 32px;
}

@@ -543,0 +556,0 @@ .review-actions button {

@@ -43,8 +43,12 @@ window.HarnessI18n = {

"workbenchDataOnly": "Workbench is active; run validated status before release decisions.",
"skipToMain": "Skip to main content",
"primaryNavigation": "Primary navigation",
"firstLook": "First look",
"projectFlow": "Project Flow",
"projectProgress": "Project Progress",
"taskProgressAria": "Task progress: {done} done / {active} active / {queued} queued / {planned} planned out of {total}",
"completed": "completed",
"done": "Done",
"active": "Active",
"queued": "Queued",
"planned": "Planned",

@@ -98,2 +102,7 @@ "nodes": "nodes",

"registeredModule": "Registered module",
"moduleSourceRegistry": "Registered",
"moduleSourceInferred": "Inferred",
"moduleSourceStructure": "Structure",
"moduleSourceGraph": "Graph",
"moduleSourceUnknown": "Unknown source",
"moduleRegistered": "Registered",

@@ -158,3 +167,3 @@ "moduleActive": "Active modules",

"statInProgress": "In progress",
"statReview": "In review",
"statReview": "Lifecycle review",
"statBlocked": "Blocked",

@@ -229,3 +238,3 @@ "statDone": "Done",

"swimlaneStageEvidence": "Evidence",
"swimlaneStageReview": "Review",
"swimlaneStageReview": "Review stage",
"swimlaneStageConfirmed": "Confirmed",

@@ -255,3 +264,3 @@ "swimlaneStageCloseout": "Closeout",

"state_closed-review-pending": "closed, review pending",
"state_confirmed-finalization-pending": "confirmed, closeout pending",
"state_confirmed-finalization-pending": "confirmed, finalization pending",
"state_lesson-finalization-pending": "confirmed, lessons pending",

@@ -281,3 +290,3 @@ "state_missing": "missing",

"reviewQueueTabs": "Lifecycle queues",
"queueReview": "Review",
"queueReview": "Human review",
"queueReviewDesc": "Only tasks whose taskQueues include review. These are ready for human review confirmation.",

@@ -291,3 +300,3 @@ "queueMissingMaterials": "Missing Materials",

"queueConfirmedFinalized": "Confirmed / Finalized",
"queueConfirmedFinalizedDesc": "Human-confirmed tasks and finalized records kept for audit and closeout follow-through.",
"queueConfirmedFinalizedDesc": "Human-confirmed tasks and finalized records kept for audit and post-confirmation finalization follow-through.",
"queueSoftDeletedSuperseded": "Soft-deleted / Superseded",

@@ -317,3 +326,3 @@ "queueSoftDeletedSupersededDesc": "Historical tasks with tombstones, replacements, merges, or archives; abandoned and duplicate semantics are shown as reasons.",

"reviewBulkSubmitting": "Confirming selected reviews...",
"reviewBulkSuccess": "Confirmed {confirmed} reviews. Reloading snapshot...",
"reviewBulkSuccess": "Confirmed {confirmed} reviews. Updating snapshot...",
"reviewBulkPartial": "Confirmed {confirmed}; {failed} failed.",

@@ -328,3 +337,3 @@ "bulkActionFailedWithReason": "{failed} failed: {reason}",

"lessonBulkSubmitting": "Creating aggregate lesson task...",
"lessonBulkSuccess": "Created 1 aggregate task for {candidates} candidates. Reloading snapshot...",
"lessonBulkSuccess": "Created 1 aggregate task for {candidates} candidates. Updating snapshot...",
"lessonBulkPartial": "Created {created}; {failed} failed.",

@@ -346,3 +355,3 @@ "openFollowUpTask": "Open follow-up task",

"reviewAlreadyConfirmed": "Human review is already confirmed.",
"reviewConfirmedCloseoutReady": "Human review is confirmed and Lesson routing is complete. This task can be closed.",
"reviewConfirmedCloseoutReady": "Human review is confirmed and Lesson routing is complete. This task can be finalized.",
"reviewConfirmedLessonPending": "Human review is confirmed, but Lesson sedimentation or promotion is still pending.",

@@ -358,8 +367,8 @@ "reviewBlocked": "Close P0-P2 open or release-blocking findings before confirming.",

"reviewSubmitting": "Submitting review confirmation...",
"reviewCompleteSuccess": "Review confirmed. Reloading snapshot...",
"reviewCompleteSuccess": "Review confirmed. Updating snapshot...",
"reviewCompleteFailed": "Review confirmation failed",
"completeTaskCloseout": "Complete Closeout",
"taskCloseoutSubmitting": "Completing closeout...",
"taskCloseoutSuccess": "Task closed. Reloading snapshot...",
"taskCloseoutFailed": "Task closeout failed",
"completeTaskCloseout": "Finalize Task",
"taskCloseoutSubmitting": "Finalizing task...",
"taskCloseoutSuccess": "Task closed. Updating snapshot...",
"taskCloseoutFailed": "Task finalization failed",
"presetCatalog": "Presets",

@@ -472,8 +481,12 @@ "presetCatalogSubtitle": "Manage project, user, and bundled task method packages. Precedence is project first, then user, then bundled.",

"workbenchDataOnly": "动态 Workbench 已连接;发布决策前请运行完整验证状态。",
"skipToMain": "跳到主内容",
"primaryNavigation": "主导航",
"firstLook": "第一眼",
"projectFlow": "项目流程图",
"projectProgress": "项目进度",
"taskProgressAria": "任务进度:共 {total} 个,{done} 个已完成 / {active} 个进行中 / {queued} 个非活跃队列 / {planned} 个待开始",
"completed": "已完成",
"done": "已完成",
"active": "进行中",
"queued": "非活跃队列",
"planned": "待开始",

@@ -527,2 +540,7 @@ "nodes": "节点",

"registeredModule": "已注册模块",
"moduleSourceRegistry": "已注册",
"moduleSourceInferred": "推断",
"moduleSourceStructure": "结构",
"moduleSourceGraph": "图谱",
"moduleSourceUnknown": "未知来源",
"moduleRegistered": "已注册",

@@ -620,3 +638,3 @@ "moduleActive": "活跃模块",

"statInProgress": "进行中",
"statReview": "审查中",
"statReview": "生命周期审查",
"statBlocked": "阻塞",

@@ -658,3 +676,3 @@ "statDone": "完成",

"swimlaneStageEvidence": "证据",
"swimlaneStageReview": "审查",
"swimlaneStageReview": "审查阶段",
"swimlaneStageConfirmed": "已确认",

@@ -709,13 +727,13 @@ "swimlaneStageCloseout": "结项",

"reviewQueueTabs": "生命周期队列",
"queueReview": "Review",
"queueReview": "人工审查",
"queueReviewDesc": "只显示 taskQueues 包含 review 的任务,这些任务等待人工审查确认。",
"queueMissingMaterials": "Missing Materials",
"queueMissingMaterials": "缺材料",
"queueMissingMaterialsDesc": "缺少审查提交、Walkthrough、lesson 判定或其他必需材料的任务。",
"queueBlocked": "Blocked",
"queueBlocked": "阻塞",
"queueBlockedDesc": "存在开放阻塞发现或状态冲突,当前不能确认审查的任务。",
"queueLessons": "Lessons",
"queueLessons": "经验沉淀",
"queueLessonsDesc": "还有 lesson candidate 或 promotion 治理动作需要处理的任务。",
"queueConfirmedFinalized": "Confirmed / Finalized",
"queueConfirmedFinalized": "已确认 / 已结项",
"queueConfirmedFinalizedDesc": "已经人工确认或最终结项的任务,用于审计和结项追踪。",
"queueSoftDeletedSuperseded": "Soft-deleted / Superseded",
"queueSoftDeletedSuperseded": "软删除 / 已替代",
"queueSoftDeletedSupersededDesc": "带 tombstone、替代、合并或归档的历史任务;废弃和重复等语义显示为原因。",

@@ -766,3 +784,3 @@ "reasonFilter": "原因筛选",

"reviewNeedsMaterial": "材料不齐",
"reviewClosedDebt": "收口待确认",
"reviewClosedDebt": "结项待确认",
"reviewBlockedQueue": "阻塞",

@@ -772,3 +790,3 @@ "reviewConfirmedQueue": "已确认",

"reviewAlreadyConfirmed": "人工审查已经确认。",
"reviewConfirmedCloseoutReady": "人工审查已确认,Lesson 路由已完成,可以结项。",
"reviewConfirmedCloseoutReady": "人工审查已确认,Lesson 路由已完成,可以最终结项。",
"reviewConfirmedLessonPending": "人工审查已确认,但还有 Lesson 沉淀或 promotion 未完成。",

@@ -786,6 +804,6 @@ "reviewBlocked": "先关闭 P0-P2 开放项或阻塞发布的发现,再确认审查。",

"reviewCompleteFailed": "审查确认失败",
"completeTaskCloseout": "完成结项",
"taskCloseoutSubmitting": "正在结项...",
"completeTaskCloseout": "最终结项",
"taskCloseoutSubmitting": "正在最终结项...",
"taskCloseoutSuccess": "任务已结项,正在刷新快照...",
"taskCloseoutFailed": "任务结项失败",
"taskCloseoutFailed": "任务最终结项失败",
"presetCatalog": "Preset",

@@ -792,0 +810,0 @@ "presetCatalogSubtitle": "管理项目级、用户级和内置任务方法包。优先级是项目级、用户级、内置。",

@@ -70,2 +70,2 @@ # {{TASK_TITLE}} - Lesson Candidates

| Missing Materials | The file is absent, has invalid status, or lacks a required no-candidate reason. | Agent repairs the candidate file. |
| Confirmed / Finalized | Human review is confirmed but a candidate still has deferred governance work. | Follow-up task or dry-run decision is recorded. |
| Confirmed / Finalized | Human review is confirmed; any candidate follow-up remains independent Lesson work. | Follow-up task or dry-run decision is recorded. |

@@ -84,3 +84,3 @@ # [Task Name] - Review

| Lessons | yes / no | Lesson candidate needs rejection, task-local retention, dry-run promotion, or a sedimentation task. | Human decides candidate routing; promotion remains a separate maintenance task unless explicitly approved. |
| Confirmed / Finalized | yes / no | Human confirmation exists; closeout or governance work may still be pending. | Closeout, ledger, and lesson routing are complete. |
| Confirmed / Finalized | yes / no | Human confirmation exists, or the task is finalized for read-only tracing. | Read-only by default; pending Lesson debt is tracked independently in Lessons. |
| Soft-deleted / Superseded | yes / no | Task has tombstone, superseded-by, or archive state; duplicate or abandoned semantics are recorded as `Reason`. | Reopen or keep as read-only audit history. |

@@ -87,0 +87,0 @@

@@ -17,5 +17,5 @@ # Execution Workflow Standard

8. Use CLI lifecycle commands for mechanical Harness writes whenever available. CLI-owned writes use locked, allowlisted auto-commit. `new-task` may preserve unrelated dirty files while committing only its own write scope; overlapping dirty paths or unrelated staged files still block the command. Other lifecycle commands may still require a clean tree. Agent-owned manual edits still need an explicit task commit or deferred-commit rationale.
9. Treat `visual_map.md` as the lifecycle phase map. `init` phases prepare work, `execution` phases define implementation completion, and `gate` phases define review, human confirmation, lesson routing, and closeout. Follow a phase `Exit Command` only when its `Actor` matches the current operator; agents must not perform `human` gates.
9. Treat `visual_map.md` as the lifecycle phase map. `init` phases prepare work, `execution` phases define implementation completion, and `gate` phases define review, human confirmation, lesson routing, the agent-owned closeout packet, and post-confirmation finalization. Follow a phase `Exit Command` only when its `Actor` matches the current operator; agents must not perform `human` gates.
10. New task directories must carry machine-readable Task Audit Metadata in `INDEX.md`. The normal path is `Created By: harness new-task`; manual creation is allowed only as `manual-exception` with a concrete reason, and historical migration must use `historical-backfill`.
11. Close the loop by updating walkthrough, SSoT, regression, ledger, or docs artifacts when the work changes durable project knowledge. New non-simple tasks should keep `lesson_candidates.md` reviewable before human review confirmation.
11. Close the loop before Human Review by updating the agent-owned closeout packet: walkthrough, evidence, review findings, lesson decisions, SSoT, regression, ledger, or docs artifacts when the work changes durable project knowledge. New non-simple tasks should keep `lesson_candidates.md` reviewable before human review confirmation.

@@ -73,2 +73,2 @@ ## Task Package Structure

Closeout must provide a concise change summary, evidence checked, checks not run with reasons, review outcome, residual risk, relevant commit SHAs or deferred-commit rationale, and any durable docs or ledger updates made during the task.
Agent-owned closeout must be ready before Human Review and must provide a concise change summary, evidence checked, checks not run with reasons, review outcome, residual risk, relevant commit SHAs or deferred-commit rationale, and any durable docs or ledger updates made during the task. Human Review Confirmation remains separate from the later `task-complete` / finalization transition.

@@ -15,3 +15,3 @@ # Long-Running Task Standard

6. Review checkpoints must challenge direction, evidence quality, and residual risk before final closeout.
7. Completion requires verification evidence and a closeout record, not just implementation completion.
7. Agent completion requires verification evidence and an agent-owned closeout record before Human Review, not just implementation completion. Human Review Confirmation and `task-complete` finalization are separate lifecycle gates.

@@ -29,2 +29,2 @@ ## Required Artifacts

Closeout must confirm the contract scope was honored, list evidence checked, identify checks not run, resolve or record material findings, and state the final residual risk. If the task stopped early, closeout must explain the stop condition and next safe action.
Agent-owned closeout must confirm the contract scope was honored, list evidence checked, identify checks not run, resolve or record material findings, and state the final residual risk. If the task stopped early, closeout must explain the stop condition and next safe action.

@@ -9,3 +9,3 @@ # Walkthrough Standard

1. Write a walkthrough for non-trivial tasks, long-running tasks, releases, cross-cutting changes, or work that future agents will need to understand.
1. Write a walkthrough for non-trivial tasks, long-running tasks, releases, cross-cutting changes, or work that future agents will need to understand. For non-simple tasks, this walkthrough is part of the agent-owned closeout packet that must be ready before Human Review.
2. A walkthrough must explain what changed, why it changed, how it was verified, what review found, and what residual risk remains.

@@ -31,2 +31,2 @@ 3. Do not paste large raw logs. Link to evidence files, commands, PRs, screenshots, or CI runs.

Walkthrough closeout is complete when a future agent can understand the delivery state, reproduce the important evidence trail, and know which residuals or lessons still matter.
Walkthrough closeout is complete when a future agent can understand the delivery state, reproduce the important evidence trail, and know which residuals or lessons still matter. That readiness is separate from Human Review Confirmation and the later `task-complete` / finalization transition.

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display