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.
@@ -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
- let specsText = specItems.join("; ")
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}`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -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
- let specsText = specItems.join("; ");
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
- function buildContext(repoRoot, activeTask, breadcrumbs) {
763
- const fallback = "Run ./.cowork-flow/run task next for the current workflow step.";
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