@worca/app 1.3.0 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
package/src/core/db.mjs CHANGED
@@ -21,6 +21,7 @@ import { mkdirSync, existsSync } from 'node:fs';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { worcaHome } from './projects.mjs';
23
23
  import { maybeMigrateFromFs } from './migrate-fs-to-db.mjs';
24
+ import { backfillHumanHours } from './human-backfill.mjs';
24
25
  import { SEED_TEMPLATES, NODE_ID_MAP, FB_WIRE_MAP } from './graph/seed-templates.mjs';
25
26
 
26
27
  const _require = createRequire(import.meta.url);
@@ -54,7 +55,7 @@ const OPEN_BACKOFF_MS = 15;
54
55
  /** Latest schema version. Bump + append a new migration step when the DDL grows.
55
56
  * Exported so migration tests assert "reached the module's current version"
56
57
  * instead of hardcoding the number — a schema bump then touches no test file. */
57
- export const SCHEMA_VERSION = 30;
58
+ export const SCHEMA_VERSION = 35;
58
59
 
59
60
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
60
61
  export function dbPath() {
@@ -306,7 +307,9 @@ CREATE INDEX idx_pipeline_events_pipeline ON pipeline_events (pipeline_id, id);
306
307
  CREATE TABLE clarify (
307
308
  pipeline_id TEXT PRIMARY KEY,
308
309
  questions TEXT, -- JSON: { questions: [ {id,question,options[2..4],allowFreeText} ] }
310
+ -- OR: the full resolved ask form { kind:'form', … } (spec §9)
309
311
  answers TEXT, -- JSON: { answers: [ {id,question,choice} ] }
312
+ -- OR: { kind:'form', form, version, values } (spec §9)
310
313
  FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
311
314
  );
312
315
 
@@ -521,7 +524,9 @@ CREATE TABLE IF NOT EXISTS step_questions (
521
524
  node_id TEXT,
522
525
  agent_key TEXT,
523
526
  questions TEXT, -- JSON: { questions: [ {id,question,options[2..4],allowFreeText} ] }
527
+ -- OR: the full resolved ask form { kind:'form', … } (spec §9)
524
528
  answers TEXT, -- JSON: { answers: [ {id,question,choice} ] }
529
+ -- OR: { kind:'form', form, version, values } (spec §9)
525
530
  PRIMARY KEY (pipeline_id, step_key, round),
526
531
  FOREIGN KEY (pipeline_id) REFERENCES pipelines (id) ON DELETE CASCADE
527
532
  );
@@ -726,6 +731,86 @@ CREATE TABLE IF NOT EXISTS ask_card_comments (
726
731
  );
727
732
  `;
728
733
 
734
+ /** v31 (scheduled runs): launch tickets, recurring schedule parents, and the generic
735
+ * notification log. A run that waits for its start time lives HERE, never in
736
+ * `pipelines`: the pipeline row is still born inside run(), so no status-aware
737
+ * reader (stats, stale sweep, delete guard, team metrics) ever meets a waiting row.
738
+ * IF NOT EXISTS + INCREMENTAL_TABLES entries: reconcile-safe on a divergently
739
+ * stamped DB. An older build simply ignores all three tables. */
740
+ const SCHEDULED_RUNS_DDL = `
741
+ CREATE TABLE IF NOT EXISTS schedules (
742
+ id TEXT PRIMARY KEY, -- 'sch_' + 8 hex
743
+ title TEXT,
744
+ project_key TEXT,
745
+ project_dir TEXT,
746
+ workspace_id TEXT,
747
+ request TEXT NOT NULL, -- JSON: POST /api/run body shape (+ internal)
748
+ rule TEXT NOT NULL, -- JSON: recurrence rule (local time + tz)
749
+ overlap TEXT NOT NULL DEFAULT 'skip', -- skip | start | queue
750
+ max_failures INTEGER NOT NULL DEFAULT 3, -- 0 = never pause
751
+ failure_streak INTEGER NOT NULL DEFAULT 0,
752
+ if_missed TEXT NOT NULL DEFAULT 'run', -- run | skip
753
+ grace_min INTEGER NOT NULL DEFAULT 360,
754
+ status TEXT NOT NULL DEFAULT 'active', -- active | paused | ended
755
+ pause_reason TEXT, -- user | failure_streak
756
+ runs_count INTEGER NOT NULL DEFAULT 0,
757
+ next_run_at TEXT,
758
+ last_result TEXT, -- last occurrence outcome (display)
759
+ ask_thread_id TEXT, -- the Ask Worca card that made it (NULL = made by hand)
760
+ ask_card_id TEXT,
761
+ created_at TEXT NOT NULL,
762
+ updated_at TEXT NOT NULL
763
+ );
764
+ CREATE TABLE IF NOT EXISTS scheduled_runs (
765
+ id TEXT PRIMARY KEY, -- the runId UUID the fired run will carry
766
+ schedule_id TEXT REFERENCES schedules(id) ON DELETE CASCADE,
767
+ title TEXT,
768
+ project_key TEXT,
769
+ project_dir TEXT,
770
+ workspace_id TEXT,
771
+ run_at TEXT NOT NULL, -- UTC ISO instant
772
+ request TEXT NOT NULL, -- JSON: POST /api/run body shape (+ internal)
773
+ status TEXT NOT NULL DEFAULT 'scheduled', -- scheduled|firing|fired|canceled|skipped|missed|failed
774
+ if_missed TEXT NOT NULL DEFAULT 'run',
775
+ grace_min INTEGER NOT NULL DEFAULT 360,
776
+ attempts INTEGER NOT NULL DEFAULT 0,
777
+ retry_at TEXT, -- next attempt after a transient start error
778
+ queued INTEGER NOT NULL DEFAULT 0, -- overlap=queue: waiting for the previous run
779
+ forced INTEGER NOT NULL DEFAULT 0, -- Run now: start at once, bypass missed/overlap checks
780
+ owner_pid INTEGER, -- a waiting --wait CLI (or the firing process)
781
+ owner_host TEXT,
782
+ pipeline_id TEXT,
783
+ fail_reason TEXT,
784
+ ask_thread_id TEXT,
785
+ ask_card_id TEXT,
786
+ after_kind TEXT, -- v34: 'ticket' | 'pipeline' | NULL (a timed ticket)
787
+ after_id TEXT, -- v34: scheduled_runs.id | pipelines.id
788
+ after_policy TEXT NOT NULL DEFAULT 'done', -- v34: done | any
789
+ source_from_previous INTEGER NOT NULL DEFAULT 0, -- v34: start on the predecessor's feature branch
790
+ created_at TEXT NOT NULL,
791
+ updated_at TEXT NOT NULL
792
+ );
793
+ CREATE INDEX IF NOT EXISTS idx_scheduled_runs_due ON scheduled_runs (status, run_at);
794
+ CREATE INDEX IF NOT EXISTS idx_scheduled_runs_schedule ON scheduled_runs (schedule_id);
795
+ CREATE INDEX IF NOT EXISTS idx_scheduled_runs_after ON scheduled_runs (after_id);
796
+ CREATE TABLE IF NOT EXISTS notifications (
797
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
798
+ scope TEXT NOT NULL DEFAULT 'schedule',
799
+ kind TEXT NOT NULL, -- missed|failed|skipped|late|paused|completed|run_error|run_paused|ended|retrying
800
+ severity TEXT NOT NULL DEFAULT 'problem', -- problem | info
801
+ schedule_id TEXT,
802
+ ticket_id TEXT,
803
+ pipeline_id TEXT,
804
+ project_dir TEXT,
805
+ title TEXT,
806
+ message TEXT NOT NULL,
807
+ created_at TEXT NOT NULL,
808
+ read_at TEXT,
809
+ resolved_at TEXT
810
+ );
811
+ CREATE INDEX IF NOT EXISTS idx_notifications_scope ON notifications (scope, created_at);
812
+ `;
813
+
729
814
  const SCHEMA_V11 = `
730
815
  ALTER TABLE config_workflow_nodes ADD COLUMN ask_questions INTEGER;
731
816
  ${STEP_QUESTIONS_DDL}
@@ -746,10 +831,14 @@ const INCREMENTAL_COLUMNS = {
746
831
  source_type: "TEXT DEFAULT 'prompt'", source_ref: 'TEXT', guardrails_id: 'TEXT',
747
832
  archived_at: 'TEXT', cost_cap_override: 'INTEGER NOT NULL DEFAULT 0',
748
833
  pr_url: 'TEXT', pr_number: 'INTEGER', pr_state: 'TEXT', pr_checked_at: 'TEXT',
749
- outcome: 'TEXT' },
834
+ outcome: 'TEXT',
835
+ scheduled_for: 'TEXT', schedule_id: 'TEXT', // v31: scheduled-run provenance (NULL = started by hand)
836
+ policy_state: 'TEXT', // v32: team-policy run state (JSON: home, sha, overrides, exceeded, deviations, reason)
837
+ human_hours: 'REAL NOT NULL DEFAULT 0' }, // v33: Σ pipeline_steps.human_hours (money-saved design §6)
750
838
  pipeline_steps: { session_id: 'TEXT', skills: 'TEXT', graphify_count: 'INTEGER',
751
839
  execution_id: 'TEXT', exec_kind: 'TEXT', agent_key: 'TEXT', ended_at: 'TEXT',
752
- exec_trigger: 'TEXT', exec_result: 'TEXT', exec_meta: 'TEXT' },
840
+ exec_trigger: 'TEXT', exec_result: 'TEXT', exec_meta: 'TEXT',
841
+ human_hours: 'REAL', human_signals: 'TEXT' }, // v33: per-execution human-hours credit + its signals JSON
753
842
  sub_agents: { ui_phase: 'TEXT', skills: 'TEXT', subagent_type: 'TEXT', graphify_count: 'INTEGER',
754
843
  run_model: 'TEXT' }, // v25: the model the child actually ran on
755
844
  workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
@@ -760,7 +849,11 @@ const INCREMENTAL_COLUMNS = {
760
849
  mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
761
850
  project_config: { human_in_loop: 'INTEGER NOT NULL DEFAULT 1' }, // v28: the Auto entry's human-in-the-loop switch
762
851
  diff_comments: { parent_id: 'TEXT REFERENCES diff_comments(id) ON DELETE CASCADE' }, // v29: reply threads; NULL = thread root
763
- workspaces: { metrics_project: 'TEXT' }, // v30: team-metrics home (member absolute path); NULL = no home
852
+ workspaces: { metrics_project: 'TEXT', // v30: team-metrics home (member absolute path); NULL = no home
853
+ policy_project: 'TEXT' }, // v32: team-policy home (member absolute path); NULL = no home
854
+ schedules: { ask_thread_id: 'TEXT', ask_card_id: 'TEXT' }, // v31: the Ask Worca card a series came from
855
+ scheduled_runs: { after_kind: 'TEXT', after_id: 'TEXT', after_policy: "TEXT NOT NULL DEFAULT 'done'",
856
+ source_from_previous: 'INTEGER NOT NULL DEFAULT 0' }, // v34: run chains
764
857
  };
765
858
 
766
859
  /** v23: per-loop-wire cycle budgets, the graph-engine twin of
@@ -801,6 +894,9 @@ const INCREMENTAL_TABLES = {
801
894
  ask_worktrees: ASK_WORKTREES_DDL,
802
895
  diff_comments: DIFF_COMMENTS_DDL,
803
896
  ask_card_comments: DIFF_COMMENTS_DDL,
897
+ schedules: SCHEDULED_RUNS_DDL,
898
+ scheduled_runs: SCHEDULED_RUNS_DDL,
899
+ notifications: SCHEDULED_RUNS_DDL,
804
900
  };
805
901
 
806
902
  /**
@@ -816,6 +912,10 @@ const INCREMENTAL_INDEXES = {
816
912
  table: 'ask_attachments',
817
913
  ddl: 'CREATE INDEX IF NOT EXISTS idx_ask_attachments_thread ON ask_attachments (thread_id)',
818
914
  },
915
+ idx_scheduled_runs_after: {
916
+ table: 'scheduled_runs',
917
+ ddl: 'CREATE INDEX IF NOT EXISTS idx_scheduled_runs_after ON scheduled_runs (after_id)',
918
+ },
819
919
  };
820
920
 
821
921
  /**
@@ -861,22 +961,29 @@ function schemaGaps(db) {
861
961
 
862
962
  /** Apply the gap repairs with NO transaction control of its own — the caller owns
863
963
  * the transaction (the ladder tx in migrate(), or reconcileSchema's own lock).
864
- * ORDER IS LOAD-BEARING: tables and indexes FIRST, then the columns RE-probed
865
- * against the post-CREATE schema. `gaps.columns` was computed BEFORE this pass
866
- * ran, so it cannot see an incremental column on a table this pass is about to
867
- * create (ask_run_links.comment_ids on a >=20-stamped DB missing the ask
868
- * tables) — the ALTER would be skipped and the DB stamped current with the
869
- * column absent, and only a LATER migrate() would heal it. No gap DDL
870
- * references an INCREMENTAL_COLUMNS column, so nothing here needs an ALTER to
871
- * run first. */
964
+ * ORDER IS LOAD-BEARING, three ways:
965
+ * 1. Columns on the tables that ALREADY exist go first. A table's DDL block may carry a
966
+ * CREATE INDEX on an INCREMENTAL_COLUMNS column (v34: SCHEDULED_RUNS_DDL's
967
+ * idx_scheduled_runs_after names scheduled_runs.after_id), and INCREMENTAL_TABLES
968
+ * re-execs the WHOLE block when any sibling table (schedules / notifications) is
969
+ * missing — CREATE TABLE IF NOT EXISTS skips the old-shape table and the index
970
+ * would throw `no such column` inside getDb().
971
+ * 2. Then the tables; then the columns RE-probed against the post-CREATE schema.
972
+ * `gaps.columns` was computed BEFORE this pass ran, so it cannot see an incremental
973
+ * column on a table this pass is about to create (ask_run_links.comment_ids on a
974
+ * >=20-stamped DB missing the ask tables) — the ALTER would be skipped and the DB
975
+ * stamped current with the column absent, and only a LATER migrate() would heal it.
976
+ * 3. INCREMENTAL_INDEXES last, for the same reason as 1. */
872
977
  function repairSchemaGaps(db, gaps) {
978
+ const addColumns = () => {
979
+ for (const { table, col, type } of missingColumns(db)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
980
+ };
981
+ addColumns();
873
982
  // One DDL block can create several tables (ASK_DDL, DIFF_COMMENTS_DDL) — the
874
983
  // Set collapses the duplicate keys to a single idempotent exec.
875
984
  for (const ddl of new Set((gaps.tables || []).map((t) => INCREMENTAL_TABLES[t]))) db.exec(ddl);
985
+ addColumns();
876
986
  for (const name of gaps.indexes || []) db.exec(INCREMENTAL_INDEXES[name].ddl);
877
- for (const { table, col, type } of missingColumns(db)) {
878
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
879
- }
880
987
  }
881
988
 
882
989
  /**
@@ -1157,6 +1264,48 @@ function applySchemaV30(db) {
1157
1264
  repairSchemaGaps(db, schemaGaps(db));
1158
1265
  }
1159
1266
 
1267
+ /** v31 (scheduled runs): schedules + scheduled_runs + notifications (INCREMENTAL_TABLES)
1268
+ * and pipelines.scheduled_for/schedule_id (INCREMENTAL_COLUMNS) — applySchemaV30's shape. */
1269
+ function applySchemaV31(db) {
1270
+ repairSchemaGaps(db, schemaGaps(db));
1271
+ }
1272
+
1273
+ /** v32 (team policy): pipelines.policy_state + workspaces.policy_project — two plain
1274
+ * additive columns declared in INCREMENTAL_COLUMNS, applySchemaV30's shape. NULL on
1275
+ * every existing row = no team policy touched this run / no policy home yet.
1276
+ * It is v32, not v31: scheduled runs claimed 31 on dev first, and a DB already
1277
+ * stamped 31 by that ladder would skip these columns forever (the v11 collision
1278
+ * this file's INCREMENTAL_COLUMNS comment describes). */
1279
+ function applySchemaV32(db) {
1280
+ repairSchemaGaps(db, schemaGaps(db));
1281
+ }
1282
+
1283
+ /** v33 (money saved): pipelines.human_hours + pipeline_steps.human_hours/human_signals —
1284
+ * additive columns declared in INCREMENTAL_COLUMNS, applySchemaV32's shape. Task 5 adds
1285
+ * the run-level backfill call for pre-v33 runs here. */
1286
+ function applySchemaV33(db) {
1287
+ repairSchemaGaps(db, schemaGaps(db));
1288
+ // Legacy credit for every terminal run recorded before v33. Best-effort by construction;
1289
+ // a throw here would roll the ladder back, so it is fenced. Runs once (the fast path
1290
+ // reconcileSchema never calls it). Cost: one read per indexed plan/review markdown.
1291
+ try { backfillHumanHours(db); } catch { /* never fail the migration on the backfill */ }
1292
+ }
1293
+
1294
+ /** v34 (run chains): scheduled_runs.after_kind/after_id/after_policy/source_from_previous + the
1295
+ * after_id index — INCREMENTAL_COLUMNS / INCREMENTAL_INDEXES, applySchemaV30's shape. */
1296
+ function applySchemaV34(db) {
1297
+ repairSchemaGaps(db, schemaGaps(db));
1298
+ }
1299
+
1300
+ /** v35 (Opus 5.5 replaces Opus 5 in PREDEFINED_MODELS): V26's catalog swap again —
1301
+ * same reasons, same stores, same rules (renameStoredModelPins). History keeps
1302
+ * recording `claude-opus-5` where that is what ran. */
1303
+ const V35_MODEL_RENAMES = [['claude-opus-5', 'claude-opus-5-5']];
1304
+
1305
+ function applySchemaV35(db) {
1306
+ for (const [from, to] of V35_MODEL_RENAMES) renameStoredModelPins(db, from, to);
1307
+ }
1308
+
1160
1309
  /** Move every stored pin on model id `from` (lower-case) to `to`. Each table
1161
1310
  * is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
1162
1311
  * fs->db import) reach this step without some of them. */
@@ -1543,6 +1692,11 @@ export function migrate(db) {
1543
1692
  if (current < 28) applySchemaV28(db); // Auto workflow: human_in_loop + flip to wf_auto
1544
1693
  if (current < 29) applySchemaV29(db); // diff-comment reply threads: parent_id
1545
1694
  if (current < 30) applySchemaV30(db); // team metrics: workspaces.metrics_project
1695
+ if (current < 31) applySchemaV31(db); // scheduled runs: tickets + schedules + notifications
1696
+ if (current < 32) applySchemaV32(db); // team policy: pipelines.policy_state + workspaces.policy_project
1697
+ if (current < 33) applySchemaV33(db); // money saved: human_hours columns
1698
+ if (current < 34) applySchemaV34(db); // run chains: scheduled_runs.after_* + source_from_previous
1699
+ if (current < 35) applySchemaV35(db); // Opus 5 pins -> Opus 5.5 (catalog swap)
1546
1700
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1547
1701
  db.exec('COMMIT');
1548
1702
  } catch (err) {
@@ -40,6 +40,10 @@ export const REASON = Object.freeze({
40
40
  ERROR: 'error', // a failure that would otherwise have ended the run
41
41
  COST_PIPELINE: 'cost_pipeline', // the per-pipeline cost cap
42
42
  COST_TOTAL: 'cost_total', // the total (weekly/monthly) cost cap
43
+ // Team policy (soft caps set on the worca-policy branch): the same two gates, but the
44
+ // binding number came from the team, so the resume flow offers "continue past".
45
+ COST_PIPELINE_POLICY: 'cost_pipeline_policy',
46
+ COST_TOTAL_POLICY: 'cost_total_policy',
43
47
  });
44
48
  export const REASON_CODES = Object.freeze(Object.values(REASON));
45
49
 
@@ -92,6 +96,10 @@ export const FAILURE_POLICY = Object.freeze({
92
96
  budget: Object.freeze({
93
97
  cost_pipeline: both(pause(REASON.COST_PIPELINE)),
94
98
  cost_total: both(pause(REASON.COST_TOTAL)),
99
+ // Team soft caps pause only when someone can click "continue past": the harness
100
+ // downgrades them to a warning under --yes before ever reaching this row.
101
+ cost_pipeline_policy: both(pause(REASON.COST_PIPELINE_POLICY)),
102
+ cost_total_policy: both(pause(REASON.COST_TOTAL_POLICY)),
95
103
  }),
96
104
  // run()'s setup — checkout, graph build, skills gate — failed with the pipeline
97
105
  // row already created. A pause here stamps `setupIncomplete`; resume replays it.
@@ -177,6 +185,8 @@ const CONSEQUENCES = Object.freeze({
177
185
  [REASON.RECOVERABLE]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'recoverable error — resume to retry' },
178
186
  [REASON.COST_PIPELINE]:{ reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'pipeline cost limit reached' },
179
187
  [REASON.COST_TOTAL]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'total cost limit reached' },
188
+ [REASON.COST_PIPELINE_POLICY]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'team cost cap reached' },
189
+ [REASON.COST_TOTAL_POLICY]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'team total cap reached' },
180
190
  [REASON.ERROR]: { reportsToSource: true, stagesResults: true, severity: 'error', notifyPref: 'error', exitInteractive: 1, label: 'a step failed' },
181
191
  });
182
192
 
@@ -8,14 +8,26 @@
8
8
  import { readdir, stat } from 'node:fs/promises';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { normalizeProjectPath } from './projects.mjs';
11
- import { defaultRoot } from './settings.mjs';
11
+ import { defaultRoot, getProjectsRoot } from './settings.mjs';
12
12
 
13
13
  function err(message, code) { return Object.assign(new Error(message), { code }); }
14
14
 
15
+ /** Where a blank listing opens: the projects root if it is a directory, else home. */
16
+ async function startFolder(home) {
17
+ const root = getProjectsRoot();
18
+ try {
19
+ if (root !== home && (await stat(root)).isDirectory()) return root;
20
+ } catch { /* missing or unreadable root: fall back to home */ }
21
+ return home;
22
+ }
23
+
15
24
  /**
16
25
  * List the sub-directories of `input` (tilde-expanded, resolved). Empty input
17
- * lists the OS home directory (normalizeProjectPath returns null for blank
18
- * input, so this can never fall through to process.cwd()).
26
+ * lists the effective projects root when it is an existing directory, else the
27
+ * OS home directory (normalizeProjectPath returns null for blank input, so this
28
+ * can never fall through to process.cwd()). With nothing configured the two are
29
+ * the same folder; in a container, WORCA_PROJECTS_ROOT names the mounted repos
30
+ * while home is an empty /home/worca. `home` stays the OS home (the Home button).
19
31
  * @param {string} input
20
32
  * @returns {Promise<{path:string, parent:string|null, home:string,
21
33
  * dirs:Array<{name:string, path:string}>}>} parent is null at the fs root.
@@ -24,7 +36,7 @@ function err(message, code) { return Object.assign(new Error(message), { code })
24
36
  */
25
37
  export async function listFolders(input) {
26
38
  const home = resolve(defaultRoot());
27
- const path = normalizeProjectPath(input) || home;
39
+ const path = normalizeProjectPath(input) || await startFolder(home);
28
40
  let entries;
29
41
  try {
30
42
  entries = await readdir(path, { withFileTypes: true });
@@ -438,6 +438,28 @@ export async function listRemotes(projectDir) {
438
438
  return { ok: true, remotes };
439
439
  }
440
440
 
441
+ /**
442
+ * The branches each named remote has, from the LOCAL remote-tracking refs
443
+ * (`refs/remotes/<remote>/*` — no network, as fresh as the last fetch). A remote
444
+ * name may itself hold a slash, so the longest matching name owns a ref; the
445
+ * symbolic `HEAD` is dropped. Never throws:
446
+ * { ok:true, byRemote:{ [name]: string[] } } | { ok:false, byRemote:{}, error }.
447
+ */
448
+ export async function listRemoteBranches(projectDir, remoteNames = []) {
449
+ if (!projectDir) return { ok: false, byRemote: {}, error: 'projectDir is required' };
450
+ const r = await _run('git', ['for-each-ref', '--format=%(refname)', 'refs/remotes/'], { cwd: projectDir });
451
+ if (!r.ok) return { ok: false, byRemote: {}, error: (r.stderr || '').trim() || `git exited ${r.code}` };
452
+ const names = [...remoteNames].sort((a, b) => b.length - a.length);
453
+ const byRemote = Object.fromEntries(remoteNames.map((n) => [n, []]));
454
+ for (const raw of (r.stdout || '').split(/\r?\n/)) {
455
+ const rest = raw.trim().startsWith('refs/remotes/') ? raw.trim().slice('refs/remotes/'.length) : '';
456
+ const name = rest && names.find((n) => rest.startsWith(`${n}/`));
457
+ const branch = name ? rest.slice(name.length + 1) : '';
458
+ if (branch && branch !== 'HEAD') byRemote[name].push(branch);
459
+ }
460
+ return { ok: true, byRemote };
461
+ }
462
+
441
463
  // Test seam: swap the command runner + clear the gh memo. Mirrors server.mjs#_testing.
442
464
  export const _testing = {
443
465
  defaultRun,
@@ -83,7 +83,9 @@ export const GRAPH_MEMORY_DEFRAG_WORKFLOW = deepFreeze({
83
83
  updatedAt: '1970-01-01T00:00:00.000Z',
84
84
  nodes: [
85
85
  { id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
86
- { id: 'n_defrag', kind: 'agent', key: 'memoryDefragmenter', x: 320, y: 200, config: {} },
86
+ // Sonnet 5 by default: a bounded restructure of a few markdown files. The template layer
87
+ // is the lowest (workflows.mjs resolveGraph), so a per-role or per-node pick still wins.
88
+ { id: 'n_defrag', kind: 'agent', key: 'memoryDefragmenter', x: 320, y: 200, config: { model: 'claude-sonnet-5' } },
87
89
  { id: 'n_end', kind: 'end', x: 600, y: 200, config: {} },
88
90
  ],
89
91
  wires: [
@@ -0,0 +1,71 @@
1
+ // src/core/graph/exec-io.mjs
2
+ //
3
+ // Execution I/O shared by every executor that publishes ports and reads a verdict:
4
+ // the agent executor (executor.mjs) and the script runner (script-runner.mjs). Moved
5
+ // out of executor.mjs so the script runner needs no executor import and no cycle;
6
+ // executor.mjs re-exports `readVerdict` and `publishable` for its existing importers.
7
+ import { existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { relative, basename } from 'node:path';
10
+
11
+ import { normalizeReview, safeParseJson } from '../protocol.mjs';
12
+
13
+ // ── verdicts ──────────────────────────────────────────────────────────────────
14
+
15
+ /** The text every unparseable verdict file fails with. */
16
+ const BAD_VERDICT_TAIL = 'expected { "issues": [ \u2026 ] }';
17
+
18
+ /**
19
+ * Read a node's verdict JSON back through the protocol normalizer.
20
+ *
21
+ * Two degenerate cases, deliberately split (they are NOT the same failure):
22
+ * - the file was NEVER WRITTEN -> `{issues: [], summary: '', missing: true}`: a clean
23
+ * pass, v1 parity, because an agent that declares a verdict and writes none must not
24
+ * fail a run. `missing` is the flag the caller turns into a warning (and the reason
25
+ * the reviews table skips the row) instead of a phantom zero-issue review.
26
+ * - the file EXISTS but does not parse, or carries no `issues` array -> THROW. The
27
+ * verifier wrote garbage, and on every shipped seed the clean side is wired straight
28
+ * to End, so "no issues" there is indistinguishable from an approval. Fail-fast owns
29
+ * the rest.
30
+ *
31
+ * `readReview` is untouched for its other callers (it is v1 code with its own tolerant
32
+ * contract); the existsSync + parse-failure branch lives here.
33
+ */
34
+ export async function readVerdict(verdictPath) {
35
+ if (!verdictPath) return { issues: [], summary: '' };
36
+ if (!existsSync(verdictPath)) return { issues: [], summary: '', missing: true };
37
+ let text;
38
+ try {
39
+ text = await readFile(verdictPath, 'utf8');
40
+ } catch (err) {
41
+ throw Object.assign(new Error(`verdict file unreadable: ${verdictPath} — ${err?.message || err}`),
42
+ { code: 'BAD_VERDICT' });
43
+ }
44
+ const data = safeParseJson(text);
45
+ if (!data || typeof data !== 'object' || !Array.isArray(data.issues)) {
46
+ throw Object.assign(new Error(`verdict file is not a review JSON: ${verdictPath} — ${BAD_VERDICT_TAIL}`),
47
+ { code: 'BAD_VERDICT' });
48
+ }
49
+ return normalizeReview(data);
50
+ }
51
+
52
+ /** The warning line a missing verdict raises, relative to the pipeline dir so the
53
+ * run log stays readable. */
54
+ export function missingVerdictWarning(ctx, verdictPath) {
55
+ const rel = ctx?.pipelineDir ? relative(ctx.pipelineDir, verdictPath) : basename(verdictPath);
56
+ return `verdict file missing: ${ctx?.nodeId || ctx?.node?.id || '?'} ${rel} — treated as clean`;
57
+ }
58
+
59
+ // ── publishing ────────────────────────────────────────────────────────────────
60
+
61
+ /** The output map the scheduler publishes from: an entry per declared port, with a
62
+ * path where one was allocated and an empty payload for void ports. Exported for
63
+ * P4's composite `finish` arm. */
64
+ export function publishable(ports, outputs) {
65
+ const out = {};
66
+ for (const port of ports?.outputs || []) {
67
+ if (!port) continue;
68
+ out[port.id] = outputs[port.id]?.path ? { path: outputs[port.id].path } : {};
69
+ }
70
+ return out;
71
+ }