cowork-flow 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/template/.claude/hooks/inject-workflow-state.py +15 -2
- package/template/.claude/settings.json +12 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +121 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +1 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +7 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +35 -0
- package/template/.cowork-flow/scripts/run.py +1 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +55 -2
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +75 -2
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +9 -2
- package/template/.cowork-flow/scripts/services/spec_check.py +493 -0
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +33 -1
- package/template/.cowork-flow/spec/backend/error-handling.md +14 -0
- package/template/.cowork-flow/spec/contracts/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/spec-checks.md +74 -0
- package/template/.opencode/plugins/cowork-flow.js +43 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +124 -6
- package/template/skills/runtime-health/scripts/doctor.py +49 -0
|
@@ -445,6 +445,43 @@ function scopeRow(entries, total, suffix) {
|
|
|
445
445
|
return `Scope: ${text}${extra} ${suffix}`
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
// Mirrors services/fact_view.py spec_digest_items: the h2 heading tree of
|
|
449
|
+
// each bound spec, injected as the Specs-row entry-name index. Format is
|
|
450
|
+
// pinned byte-for-byte with the Python source (contract fingerprint tests):
|
|
451
|
+
// path(h2a/h2b), at most 6 headings, each truncated to 24 chars after
|
|
452
|
+
// stripping "();" characters; missing files stay unannotated.
|
|
453
|
+
const SPEC_DIGEST_MAX_HEADINGS = 6
|
|
454
|
+
const SPEC_DIGEST_MAX_CHARS = 24
|
|
455
|
+
|
|
456
|
+
function specHeadingDigest(specFile, root) {
|
|
457
|
+
let text
|
|
458
|
+
try {
|
|
459
|
+
text = readFileSync(resolve(root, specFile), "utf8")
|
|
460
|
+
} catch {
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
const headings = []
|
|
464
|
+
for (const line of text.split(/\r?\n/)) {
|
|
465
|
+
if (line.startsWith("## ") && !line.startsWith("###")) {
|
|
466
|
+
const cleaned = line.slice(3).replace(/[();]/g, "").trim().slice(0, SPEC_DIGEST_MAX_CHARS)
|
|
467
|
+
if (cleaned) {
|
|
468
|
+
headings.push(cleaned)
|
|
469
|
+
if (headings.length >= SPEC_DIGEST_MAX_HEADINGS) break
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return headings.length > 0 ? headings.join("/") : null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function specDigestItems(root, specFiles) {
|
|
477
|
+
const map = {}
|
|
478
|
+
for (const item of specFiles) {
|
|
479
|
+
const digest = specHeadingDigest(item, root)
|
|
480
|
+
if (digest) map[item] = digest
|
|
481
|
+
}
|
|
482
|
+
return map
|
|
483
|
+
}
|
|
484
|
+
|
|
448
485
|
// Mirrors services/fact_view.py::_fit_stage_contract: degrade an over-budget
|
|
449
486
|
// block without ever emitting a malformed one — closing tag and guard rows
|
|
450
487
|
// (Scope/Gates) always survive. Keep row-role rules and drop order identical.
|
|
@@ -536,7 +573,12 @@ function stageContractBlock(root, taskPath, status, readonly = false) {
|
|
|
536
573
|
))
|
|
537
574
|
if (specFiles.length > 0) {
|
|
538
575
|
const specItems = specFiles.slice(0, specLimit)
|
|
539
|
-
|
|
576
|
+
const digestMap = specDigestItems(root, specFiles)
|
|
577
|
+
const parts = specItems.map((item) => {
|
|
578
|
+
const digest = digestMap[item]
|
|
579
|
+
return digest ? `${item}(${digest})` : item
|
|
580
|
+
})
|
|
581
|
+
let specsText = parts.join("; ")
|
|
540
582
|
const specMore = specFiles.length - specItems.length
|
|
541
583
|
if (specMore > 0) specsText += ` (+${specMore} more)`
|
|
542
584
|
lines.push(`Specs: ${specsText}`)
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
10
10
|
import { join, dirname } from "path";
|
|
11
11
|
import { createHash } from "crypto";
|
|
12
|
+
import { spawnSync } from "child_process";
|
|
12
13
|
|
|
13
14
|
const DIR_WORKFLOW = ".cowork-flow";
|
|
14
15
|
const FILE_TASK_JSON = "task.json";
|
|
@@ -611,6 +612,47 @@ function scopeRow(entries, total, suffix) {
|
|
|
611
612
|
return `Scope: ${text}${extra} ${suffix}`;
|
|
612
613
|
}
|
|
613
614
|
|
|
615
|
+
// Mirrors services/fact_view.py spec_digest_items: the h2 heading tree of
|
|
616
|
+
// each bound spec, injected as the Specs-row entry-name index. Format is
|
|
617
|
+
// pinned byte-for-byte with the Python source (contract fingerprint tests):
|
|
618
|
+
// path(h2a/h2b), at most 6 headings, each truncated to 24 chars after
|
|
619
|
+
// stripping "();" characters; missing files stay unannotated.
|
|
620
|
+
const SPEC_DIGEST_MAX_HEADINGS = 6;
|
|
621
|
+
const SPEC_DIGEST_MAX_CHARS = 24;
|
|
622
|
+
|
|
623
|
+
function specHeadingDigest(specFile, repoRoot) {
|
|
624
|
+
let text;
|
|
625
|
+
try {
|
|
626
|
+
text = readFileSync(join(repoRoot, specFile), "utf8");
|
|
627
|
+
} catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
const headings = [];
|
|
631
|
+
for (const line of text.split(/\r?\n/)) {
|
|
632
|
+
if (line.startsWith("## ") && !line.startsWith("###")) {
|
|
633
|
+
const cleaned = line
|
|
634
|
+
.slice(3)
|
|
635
|
+
.replace(/[();]/g, "")
|
|
636
|
+
.trim()
|
|
637
|
+
.slice(0, SPEC_DIGEST_MAX_CHARS);
|
|
638
|
+
if (cleaned) {
|
|
639
|
+
headings.push(cleaned);
|
|
640
|
+
if (headings.length >= SPEC_DIGEST_MAX_HEADINGS) break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return headings.length > 0 ? headings.join("/") : null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function specDigestItems(repoRoot, specFiles) {
|
|
648
|
+
const map = {};
|
|
649
|
+
for (const item of specFiles) {
|
|
650
|
+
const digest = specHeadingDigest(item, repoRoot);
|
|
651
|
+
if (digest) map[item] = digest;
|
|
652
|
+
}
|
|
653
|
+
return map;
|
|
654
|
+
}
|
|
655
|
+
|
|
614
656
|
// Mirrors services/fact_view.py::_fit_stage_contract: degrade an over-budget
|
|
615
657
|
// block without ever emitting a malformed one — the closing tag and the guard
|
|
616
658
|
// rows (Scope/Gates) always survive. Keep the row-role rules and drop order
|
|
@@ -692,7 +734,12 @@ function stageContractBlock(repoRoot, taskPath, status, readonly = false) {
|
|
|
692
734
|
));
|
|
693
735
|
if (specFiles.length > 0) {
|
|
694
736
|
const specItems = specFiles.slice(0, specLimit);
|
|
695
|
-
|
|
737
|
+
const digestMap = specDigestItems(repoRoot, specFiles);
|
|
738
|
+
const parts = specItems.map((item) => {
|
|
739
|
+
const digest = digestMap[item];
|
|
740
|
+
return digest ? `${item}(${digest})` : item;
|
|
741
|
+
});
|
|
742
|
+
let specsText = parts.join("; ");
|
|
696
743
|
const specMore = specFiles.length - specItems.length;
|
|
697
744
|
if (specMore > 0) specsText += ` (+${specMore} more)`;
|
|
698
745
|
lines.push(`Specs: ${specsText}`);
|
|
@@ -759,8 +806,81 @@ function editScopeWarning(input, filePath) {
|
|
|
759
806
|
return JSON.stringify(payload, null, 2);
|
|
760
807
|
}
|
|
761
808
|
|
|
762
|
-
|
|
763
|
-
|
|
809
|
+
// Editor-phase spec-check short path (mirrors workflow_state_hook.py
|
|
810
|
+
// spec_edit_warning): executes the single-source spec_check.py CLI in
|
|
811
|
+
// throttled single-line mode. Same silence rules as editScopeWarning —
|
|
812
|
+
// only active in_progress/review main sessions get advisories. The 3.8s
|
|
813
|
+
// spawn ceiling keeps the whole hook inside its 5s budget (the check
|
|
814
|
+
// itself clamps to 3s; throttle state lives in .cowork-flow/.runtime so
|
|
815
|
+
// edit storms cannot multiply command costs).
|
|
816
|
+
function mergedEditWarning(input, filePath) {
|
|
817
|
+
const scopeJson = editScopeWarning(input, filePath);
|
|
818
|
+
const specLine = specEditWarningLine(input, filePath);
|
|
819
|
+
if (!specLine) return scopeJson;
|
|
820
|
+
let context = "";
|
|
821
|
+
if (scopeJson) {
|
|
822
|
+
try {
|
|
823
|
+
context = JSON.parse(scopeJson)?.hookSpecificOutput?.additionalContext || "";
|
|
824
|
+
} catch {
|
|
825
|
+
context = "";
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
context = context ? `${context}\n${specLine}` : specLine;
|
|
829
|
+
return JSON.stringify({
|
|
830
|
+
hookSpecificOutput: {
|
|
831
|
+
hookEventName: "PostToolUse",
|
|
832
|
+
additionalContext: context,
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function specEditWarningLine(input, filePath) {
|
|
838
|
+
const root = findProjectRoot(input);
|
|
839
|
+
if (!root) return "";
|
|
840
|
+
const activeTask = readActiveTask(root, resolveSessionKey(input));
|
|
841
|
+
if (!activeTask || !activeTask.taskPath) return "";
|
|
842
|
+
if (activeTask.scope === "subagent") return "";
|
|
843
|
+
const { status, missing } = readTaskStatus(root, activeTask.taskPath);
|
|
844
|
+
if (missing || !STAGE_CONTRACT_STATES.includes(status)) return "";
|
|
845
|
+
return spawnSpecCheckEdit(root, filePath);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function pythonCandidates() {
|
|
849
|
+
const list = [];
|
|
850
|
+
const env = (process.env.COWORK_FLOW_PYTHON || "").trim();
|
|
851
|
+
if (env) list.push([env]);
|
|
852
|
+
list.push(["python3"], ["python"]);
|
|
853
|
+
if (process.platform === "win32") list.push(["py", "-3"]);
|
|
854
|
+
return list;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function spawnSpecCheckEdit(root, filePath) {
|
|
858
|
+
const script = join(
|
|
859
|
+
root,
|
|
860
|
+
DIR_WORKFLOW,
|
|
861
|
+
"scripts",
|
|
862
|
+
"adapters",
|
|
863
|
+
"cli",
|
|
864
|
+
"spec_check.py"
|
|
865
|
+
);
|
|
866
|
+
for (const candidate of pythonCandidates()) {
|
|
867
|
+
let result;
|
|
868
|
+
try {
|
|
869
|
+
result = spawnSync(candidate[0], [...candidate.slice(1), script, "--phase", "edit", "--file", filePath, "--throttled"], {
|
|
870
|
+
cwd: root,
|
|
871
|
+
timeout: 3800,
|
|
872
|
+
encoding: "utf8",
|
|
873
|
+
});
|
|
874
|
+
} catch {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (result.error || result.status === null) continue;
|
|
878
|
+
return (result.stdout || "").split(/\r?\n/)[0].trim();
|
|
879
|
+
}
|
|
880
|
+
return "";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function buildContext(repoRoot, activeTask, breadcrumbs) { const fallback = "Run ./.cowork-flow/run task next for the current workflow step.";
|
|
764
884
|
|
|
765
885
|
if (!activeTask) {
|
|
766
886
|
const body = breadcrumbs.no_task || fallback;
|
|
@@ -841,9 +961,7 @@ function main() {
|
|
|
841
961
|
typeof editedPath === "string" &&
|
|
842
962
|
editedPath.trim()
|
|
843
963
|
) {
|
|
844
|
-
process.stdout.write(
|
|
845
|
-
editScopeWarning(input, editedPath.trim())
|
|
846
|
-
);
|
|
964
|
+
process.stdout.write(mergedEditWarning(input, editedPath.trim()));
|
|
847
965
|
process.exit(0);
|
|
848
966
|
}
|
|
849
967
|
// Normalize separators so Windows run.cmd invocations still match; the
|
|
@@ -595,6 +595,52 @@ def check_runtime(repo_root: Path) -> list[str]:
|
|
|
595
595
|
return errors
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
def check_spec_checks(repo_root: Path) -> list[dict[str, str]]:
|
|
599
|
+
"""Spec-check declaration health: parse errors and command entry
|
|
600
|
+
existence. Execution belongs to `run spec-check`; doctor only proves
|
|
601
|
+
the declarations can run, before a completion gate finds out they
|
|
602
|
+
cannot."""
|
|
603
|
+
issues: list[dict[str, str]] = []
|
|
604
|
+
import shutil
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
from services.spec_check import collect_spec_declarations
|
|
608
|
+
except Exception as error:
|
|
609
|
+
return [
|
|
610
|
+
{
|
|
611
|
+
"kind": "spec-checks",
|
|
612
|
+
"spec": "",
|
|
613
|
+
"message": f"spec-check service unavailable: {error}",
|
|
614
|
+
}
|
|
615
|
+
]
|
|
616
|
+
for spec_decls in collect_spec_declarations(repo_root):
|
|
617
|
+
for message in spec_decls.errors:
|
|
618
|
+
issues.append(
|
|
619
|
+
{"kind": "spec-checks", "spec": spec_decls.spec, "message": message}
|
|
620
|
+
)
|
|
621
|
+
for decl in spec_decls.decls:
|
|
622
|
+
entry = (decl.cmd_win if sys.platform == "win32" else decl.cmd) or decl.cmd
|
|
623
|
+
first_token = entry.split()[0] if entry.split() else ""
|
|
624
|
+
if not first_token:
|
|
625
|
+
issues.append(
|
|
626
|
+
{
|
|
627
|
+
"kind": "spec-checks",
|
|
628
|
+
"spec": spec_decls.spec,
|
|
629
|
+
"message": f"empty command declaration: {decl.cmd!r}",
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
continue
|
|
633
|
+
if shutil.which(first_token) is None:
|
|
634
|
+
issues.append(
|
|
635
|
+
{
|
|
636
|
+
"kind": "spec-checks",
|
|
637
|
+
"spec": spec_decls.spec,
|
|
638
|
+
"message": f"command entry not found: {first_token}",
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
return issues
|
|
642
|
+
|
|
643
|
+
|
|
598
644
|
def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
599
645
|
host_issues = _host_issues(repo_root)
|
|
600
646
|
runtime_errors = check_runtime(repo_root)
|
|
@@ -602,6 +648,7 @@ def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
|
602
648
|
task_hygiene_issues = check_task_hygiene(repo_root)
|
|
603
649
|
state_recovery_issues = check_state_recovery(repo_root)
|
|
604
650
|
session_hygiene_issues = check_session_hygiene(repo_root)
|
|
651
|
+
spec_check_issues = check_spec_checks(repo_root)
|
|
605
652
|
errors: list[dict[str, object]] = []
|
|
606
653
|
for issue in host_issues:
|
|
607
654
|
errors.append({"kind": "host_adapter", **issue})
|
|
@@ -613,6 +660,7 @@ def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
|
613
660
|
{"kind": "distribution", "message": error}
|
|
614
661
|
for error in distribution_errors
|
|
615
662
|
)
|
|
663
|
+
errors.extend({"kind": "spec-checks", **issue} for issue in spec_check_issues)
|
|
616
664
|
return {
|
|
617
665
|
"ok": not errors,
|
|
618
666
|
"errors": errors,
|
|
@@ -621,6 +669,7 @@ def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
|
621
669
|
"taskHygiene": task_hygiene_issues,
|
|
622
670
|
"stateRecovery": state_recovery_issues,
|
|
623
671
|
"sessionHygiene": session_hygiene_issues,
|
|
672
|
+
"specChecks": spec_check_issues,
|
|
624
673
|
},
|
|
625
674
|
}
|
|
626
675
|
|