@worca/app 1.2.0 → 1.3.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.
Files changed (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { getDb, prepare, tx } from './db.mjs';
16
16
  import { projectKey } from './store.mjs';
17
+ import { AUTO_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
17
18
  import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
18
19
  import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
19
20
  import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels } from './settings.mjs';
@@ -136,7 +137,7 @@ function parseJson(text, fallback) {
136
137
  function readConfigRow(key) {
137
138
  getDb();
138
139
  return prepare(
139
- 'SELECT steps, custom_models, active_workflow_id, extra FROM project_config WHERE project_key = ?'
140
+ 'SELECT steps, custom_models, active_workflow_id, extra, human_in_loop FROM project_config WHERE project_key = ?'
140
141
  ).get(key) || null;
141
142
  }
142
143
 
@@ -838,11 +839,18 @@ export async function readRunConfig(projectDir) {
838
839
  const extra = row ? parseJson(row.extra, {}) : {};
839
840
  if (extra.webUiTesting && typeof extra.webUiTesting === 'object') out.webUiTesting = extra.webUiTesting;
840
841
  // Forward any OTHER unknown keys verbatim too (future-proof, matches "preserve unknown").
842
+ // prRemotes is the ship-it dialog's own preference (readPrRemotePrefs), not run config.
841
843
  for (const [k, v] of Object.entries(extra)) {
842
- if (k !== 'webUiTesting' && !(k in out)) out[k] = v;
844
+ if (k !== 'webUiTesting' && k !== PR_REMOTES_KEY && k !== TEAM_METRICS_KEY && !(k in out)) out[k] = v;
843
845
  }
844
846
  const active = row && typeof row.active_workflow_id === 'string' ? row.active_workflow_id.trim() : '';
845
- if (active) out.activeWorkflowId = active;
847
+ // Spec §6.1 / D16: a project with no remembered New-pipeline choice starts on Auto.
848
+ out.activeWorkflowId = active || AUTO_WORKFLOW_ID;
849
+ // Auto workflow (spec §6.1): the human-in-the-loop switch. ON is the default
850
+ // and is NOT echoed — the key appears only when the project turned it off, so
851
+ // every consumer reads `config.humanInLoop ?? true` and the config shape of a
852
+ // project that never touched it stays otherwise byte-identical.
853
+ if (row && row.human_in_loop === 0) out.humanInLoop = false;
846
854
  return out;
847
855
  }
848
856
 
@@ -1010,6 +1018,115 @@ export async function setActiveWorkflow(projectDir, workflowId) {
1010
1018
  });
1011
1019
  }
1012
1020
 
1021
+ // ── PR remote preferences (project_config.extra.prRemotes) ──────────────────
1022
+ // The History "Ship it?" dialog remembers which remote the branch was pushed to
1023
+ // and which repo the PR was opened in. Stored inside the free-form `extra` JSON
1024
+ // column — the FIRST runtime writer of that column: a read-modify-write of this
1025
+ // ONE key inside a tx, leaving every other top-level key of `extra` byte-identical
1026
+ // (test/config-db.test.mjs pins that for the sibling writers, which upsert only
1027
+ // their own columns and never touch `extra` on conflict).
1028
+ const PR_REMOTES_KEY = 'prRemotes';
1029
+
1030
+ function sanitizeRemoteName(v) {
1031
+ const s = typeof v === 'string' ? v.trim() : '';
1032
+ return s && s.length <= 200 ? s : null;
1033
+ }
1034
+
1035
+ /**
1036
+ * Remembered push/base remote names for a project, or null when none.
1037
+ * @param {string} projectDir
1038
+ * @returns {{ pushRemote:(string|null), baseRemote:(string|null) }|null}
1039
+ */
1040
+ export function readPrRemotePrefs(projectDir) {
1041
+ const row = readConfigRow(projectKey(projectDir));
1042
+ const extra = row ? parseJson(row.extra, {}) : {};
1043
+ const p = extra[PR_REMOTES_KEY];
1044
+ if (!p || typeof p !== 'object') return null;
1045
+ const pushRemote = sanitizeRemoteName(p.pushRemote);
1046
+ const baseRemote = sanitizeRemoteName(p.baseRemote);
1047
+ return pushRemote || baseRemote ? { pushRemote, baseRemote } : null;
1048
+ }
1049
+
1050
+ /** Remember the dialog's choice. Only `extra.prRemotes` changes; every other column/key is preserved. */
1051
+ export async function setPrRemotePrefs(projectDir, { pushRemote, baseRemote } = {}) {
1052
+ const key = projectKey(projectDir);
1053
+ const next = { pushRemote: sanitizeRemoteName(pushRemote), baseRemote: sanitizeRemoteName(baseRemote) };
1054
+ tx(() => {
1055
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1056
+ const extra = row ? parseJson(row.extra, {}) : {};
1057
+ extra[PR_REMOTES_KEY] = next;
1058
+ prepare(`
1059
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
1060
+ VALUES (?, '{}', '[]', NULL, ?)
1061
+ ON CONFLICT(project_key) DO UPDATE SET extra = excluded.extra
1062
+ `).run(key, JSON.stringify(extra));
1063
+ });
1064
+ }
1065
+
1066
+ // ── Team-metrics preferences (project_config.extra.teamMetrics) ────────────
1067
+ // The discovery cache + local enable state for the team-metrics feature (§4.6).
1068
+ // Same read-modify-write pattern as prRemotes above, one key of the same `extra` blob.
1069
+ export const TEAM_METRICS_KEY = 'teamMetrics';
1070
+
1071
+ /**
1072
+ * NOTE THE PARAMETER. Unlike its siblings `readPrRemotePrefs(projectDir)` /
1073
+ * `setPrRemotePrefs(projectDir, …)` above, which take a DIRECTORY and call
1074
+ * `projectKey()` themselves, these two take the KEY. Passing a path is not a type error — it is a
1075
+ * valid SQL parameter that matches no row, so the call silently returns null, which reads as
1076
+ * "not enabled" and drops every record. `assertProjectKey` makes that a loud failure instead.
1077
+ */
1078
+ function assertProjectKey(key) {
1079
+ if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(key)) {
1080
+ throw new TypeError(`team metrics prefs take a projectKey(), not ${JSON.stringify(key)} — did you pass a directory?`);
1081
+ }
1082
+ return key;
1083
+ }
1084
+
1085
+ /** @returns {object|null} the cached team-metrics state for a project key */
1086
+ export function readTeamMetricsPrefs(key) {
1087
+ assertProjectKey(key);
1088
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1089
+ const extra = row ? parseJson(row.extra, {}) : {};
1090
+ const v = extra[TEAM_METRICS_KEY];
1091
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
1092
+ }
1093
+
1094
+ /** Shallow-merge `patch` into extra.teamMetrics; returns the merged object. */
1095
+ export function writeTeamMetricsPrefs(key, patch) {
1096
+ assertProjectKey(key);
1097
+ let next = null;
1098
+ tx(() => {
1099
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1100
+ const extra = row ? parseJson(row.extra, {}) : {};
1101
+ const cur = extra[TEAM_METRICS_KEY] && typeof extra[TEAM_METRICS_KEY] === 'object' ? extra[TEAM_METRICS_KEY] : {};
1102
+ next = { ...cur, ...patch };
1103
+ extra[TEAM_METRICS_KEY] = next;
1104
+ prepare(`
1105
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
1106
+ VALUES (?, '{}', '[]', NULL, ?)
1107
+ ON CONFLICT(project_key) DO UPDATE SET extra = excluded.extra
1108
+ `).run(key, JSON.stringify(extra));
1109
+ });
1110
+ return next;
1111
+ }
1112
+
1113
+ /**
1114
+ * Set the project's human-in-the-loop switch for Auto runs (spec D15/D20).
1115
+ * @param {string} projectDir
1116
+ * @param {boolean} value
1117
+ */
1118
+ export async function setHumanInLoop(projectDir, value) {
1119
+ const key = projectKey(projectDir);
1120
+ const v = value === false ? 0 : 1;
1121
+ tx(() => {
1122
+ prepare(`
1123
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra, human_in_loop)
1124
+ VALUES (?, '{}', '[]', NULL, '{}', ?)
1125
+ ON CONFLICT(project_key) DO UPDATE SET human_in_loop = excluded.human_in_loop
1126
+ `).run(key, v);
1127
+ });
1128
+ }
1129
+
1013
1130
  /**
1014
1131
  * Resolve just the run-config for one workflow into { nodes, feedbacks } maps
1015
1132
  * (the inputs resolveWorkflow overlays on the template). Unconfigured => empties.
package/src/core/db.mjs CHANGED
@@ -54,7 +54,7 @@ const OPEN_BACKOFF_MS = 15;
54
54
  /** Latest schema version. Bump + append a new migration step when the DDL grows.
55
55
  * Exported so migration tests assert "reached the module's current version"
56
56
  * instead of hardcoding the number — a schema bump then touches no test file. */
57
- export const SCHEMA_VERSION = 27;
57
+ export const SCHEMA_VERSION = 30;
58
58
 
59
59
  /** Absolute path to the database file: <worcaHome>/worca-cc.db. */
60
60
  export function dbPath() {
@@ -166,6 +166,9 @@ CREATE UNIQUE INDEX idx_projects_name ON projects (name COLLATE NOCASE);
166
166
 
167
167
  -- workspaces: named sets of 2+ projects (was workspaces.json header fields).
168
168
  -- id is the frozen workspaceKey (wks-<slug>-<sha1[:8]>). name is CI-unique.
169
+ -- metrics_project (v30, added via INCREMENTAL_COLUMNS/ladder, NOT in this base
170
+ -- DDL — matches diff_comments.parent_id) holds the team-metrics home: one
171
+ -- member's absolute path, or NULL for no home configured.
169
172
  CREATE TABLE workspaces (
170
173
  id TEXT PRIMARY KEY,
171
174
  name TEXT NOT NULL COLLATE NOCASE,
@@ -752,8 +755,12 @@ const INCREMENTAL_COLUMNS = {
752
755
  workflows: { domain: 'TEXT', origin: 'TEXT', graph: 'TEXT', archived_at: 'TEXT' },
753
756
  config_workflow_nodes: { ask_questions: 'INTEGER', subagent_model: 'TEXT' }, // v25: sub-agent model policy
754
757
  ask_run_links: { comment_ids: 'TEXT' }, // v22: JSON array of dc_ ids pending at launch
758
+ artifacts: { step_key: 'TEXT', node_id: 'TEXT', cycle: 'INTEGER', created_at: 'TEXT' }, // per-step attribution
755
759
  ask_attachments: { kind: "TEXT NOT NULL DEFAULT 'text'", // v27: text | image | binary (#398)
756
760
  mime: 'TEXT' }, // v27: sniffed mime; NULL on pre-v27 rows (= text)
761
+ project_config: { human_in_loop: 'INTEGER NOT NULL DEFAULT 1' }, // v28: the Auto entry's human-in-the-loop switch
762
+ 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
757
764
  };
758
765
 
759
766
  /** v23: per-loop-wire cycle budgets, the graph-engine twin of
@@ -1117,6 +1124,39 @@ function applySchemaV27(db) {
1117
1124
  repairSchemaGaps(db, schemaGaps(db));
1118
1125
  }
1119
1126
 
1127
+ /** v28 (Auto workflow, spec §6.1 / D16): project_config.human_in_loop (additive,
1128
+ * declared in INCREMENTAL_COLUMNS — this repairSchemaGaps call CREATES it on the
1129
+ * ladder path; reconcileSchema covers a DB another build stamped past 28, adding
1130
+ * the column but NEVER repeating the flip) and the ONE-TIME flip of every project's
1131
+ * remembered New-pipeline choice to the Auto entry. The flip lives on the ladder
1132
+ * only: a later choice is never overwritten by a reopen. */
1133
+ function applySchemaV28(db) {
1134
+ repairSchemaGaps(db, schemaGaps(db));
1135
+ if (hasSqliteTable(db, 'project_config')) {
1136
+ db.prepare("UPDATE project_config SET active_workflow_id = 'wf_auto'").run();
1137
+ }
1138
+ }
1139
+
1140
+ /** v29 (diff-comment reply threads): diff_comments.parent_id — a plain additive
1141
+ * column declared in INCREMENTAL_COLUMNS, applySchemaV27's shape: this
1142
+ * repairSchemaGaps call CREATES it on the ladder path (a DB stamped exactly 28),
1143
+ * reconcileSchema covers the fast path. The type string carries the REFERENCES
1144
+ * clause verbatim into `ALTER TABLE … ADD COLUMN`, which SQLite accepts under
1145
+ * foreign_keys=ON because the default is NULL. Every existing row stays NULL = a
1146
+ * thread root; nothing is backfilled. */
1147
+ function applySchemaV29(db) {
1148
+ repairSchemaGaps(db, schemaGaps(db));
1149
+ }
1150
+
1151
+ /** v30 (team metrics home): workspaces.metrics_project — a plain additive column
1152
+ * declared in INCREMENTAL_COLUMNS, applySchemaV29's shape: this repairSchemaGaps
1153
+ * call CREATES it on the ladder path (a DB stamped exactly 29), reconcileSchema
1154
+ * covers the fast path (a DB already stamped >= 30 by a divergent ladder). NULL
1155
+ * on every existing row = no team-metrics home configured yet. */
1156
+ function applySchemaV30(db) {
1157
+ repairSchemaGaps(db, schemaGaps(db));
1158
+ }
1159
+
1120
1160
  /** Move every stored pin on model id `from` (lower-case) to `to`. Each table
1121
1161
  * is guarded like V24's: hand-seeded upgrade fixtures (and a DB from before the
1122
1162
  * fs->db import) reach this step without some of them. */
@@ -1500,6 +1540,9 @@ export function migrate(db) {
1500
1540
  if (current < 25) applySchemaV25(db); // sub-agent model policy + recorded child model
1501
1541
  if (current < 26) applySchemaV26(db); // Fable 5 pins -> Fable 5.1 (catalog swap)
1502
1542
  if (current < 27) applySchemaV27(db); // ask_attachments.kind/mime (#398)
1543
+ if (current < 28) applySchemaV28(db); // Auto workflow: human_in_loop + flip to wf_auto
1544
+ if (current < 29) applySchemaV29(db); // diff-comment reply threads: parent_id
1545
+ if (current < 30) applySchemaV30(db); // team metrics: workspaces.metrics_project
1503
1546
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1504
1547
  db.exec('COMMIT');
1505
1548
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  // src/core/diff-comments.mjs
2
- // Internal, line-anchored comments on a run's persisted diff (diff_comments, v22).
2
+ // Internal, line-anchored comments on a run's persisted diff (diff_comments, v22;
3
+ // reply threads via parent_id, v29).
3
4
  // The ONE mutation module: the REST routes in ui/server.mjs and the Ask MCP tools
4
5
  // (through src/core/ask/comment-deps.mjs) both write through here, so anchor
5
6
  // validation, the protected-path floor, the body cap and the change notification
@@ -35,6 +36,7 @@ function rowToComment(r) {
35
36
  body: r.body, author: r.author,
36
37
  resolved: !!r.resolved, resolvedAt: r.resolved_at ?? null,
37
38
  sentRunId: r.sent_run_id ?? null, createdAt: r.created_at,
39
+ parentId: r.parent_id ?? null,
38
40
  };
39
41
  }
40
42
 
@@ -89,7 +91,8 @@ export function listDiffComments(storeKey, pipelineId, { status = 'all', path =
89
91
  ORDER BY path, line_no, rowid`).all(...vals).map(rowToComment);
90
92
  }
91
93
 
92
- /** Unresolved counts keyed "<storeKey>/<pipelineId>", newest-commented first and
94
+ /** Unresolved counts keyed "<storeKey>/<pipelineId>" — roots only: a reply is part
95
+ * of its thread, not an item. Newest-commented first and
93
96
  * hard-capped: the endpoint fans out to every open tab on every poke, and an
94
97
  * unbounded row-per-commented-run response is the one part of it that grows with
95
98
  * history. 5000 is a backstop, not a paging story — /api/history is itself
@@ -98,7 +101,7 @@ export function unresolvedCounts() {
98
101
  getDb();
99
102
  const out = {};
100
103
  for (const r of prepare(`SELECT store_key, pipeline_id, count(*) AS n, max(rowid) AS last
101
- FROM diff_comments WHERE resolved = 0
104
+ FROM diff_comments WHERE resolved = 0 AND parent_id IS NULL
102
105
  GROUP BY store_key, pipeline_id
103
106
  ORDER BY last DESC LIMIT 5000`).all()) {
104
107
  out[`${r.store_key}/${r.pipeline_id}`] = r.n;
@@ -108,6 +111,14 @@ export function unresolvedCounts() {
108
111
 
109
112
  // ── writes ──────────────────────────────────────────────────────────────────
110
113
 
114
+ /** Trimmed body, or a DiffCommentError — the one place the cap is enforced. */
115
+ function cleanBody(body) {
116
+ const text = typeof body === 'string' ? body.trim() : '';
117
+ if (!text) throw new DiffCommentError('body is required');
118
+ if (text.length > COMMENT_BODY_MAX) throw new DiffCommentError(`body exceeds ${COMMENT_BODY_MAX} characters`);
119
+ return text;
120
+ }
121
+
111
122
  /**
112
123
  * Create one comment. `patchText` is the run's diff-patch.patch as READ BY THE
113
124
  * CALLER (the server through readRunArtifactText, the MCP bundle through
@@ -123,9 +134,7 @@ export function addDiffComment({
123
134
  throw new DiffCommentError('this run has no stored diff — comments cannot be created on it');
124
135
  }
125
136
  if (!COMMENT_AUTHORS.includes(author)) throw new DiffCommentError('author must be "user" or "ask"');
126
- const text = typeof body === 'string' ? body.trim() : '';
127
- if (!text) throw new DiffCommentError('body is required');
128
- if (text.length > COMMENT_BODY_MAX) throw new DiffCommentError(`body exceeds ${COMMENT_BODY_MAX} characters`);
137
+ const text = cleanBody(body);
129
138
 
130
139
  let anchor;
131
140
  try {
@@ -147,14 +156,46 @@ export function addDiffComment({
147
156
  return getDiffComment(id);
148
157
  }
149
158
 
150
- /** Toggle. Returns the updated comment, or null when the id is unknown. */
159
+ /**
160
+ * Reply inside a thread (D1–D3). The parent must be a ROOT; the reply copies the
161
+ * root's anchor and its resolved state, so every reader keeps working without a
162
+ * join: ordering (same path/line, later rowid), the status filters, the
163
+ * protected-path guard (same path/old_path) and the line_text snapshot.
164
+ * No patch is needed — a reply anchors to nothing new, so there is no anchor to
165
+ * resolve and nothing to resolve it against. (Not an archived-run affordance:
166
+ * archiving deletes a run's comments, pipeline-delete.mjs.)
167
+ * @throws {DiffCommentError}
168
+ */
169
+ export function addDiffCommentReply({ parentId, body, author } = {}) {
170
+ const parent = getDiffComment(parentId);
171
+ if (!parent) throw new DiffCommentError('comment not found');
172
+ if (parent.parentId) throw new DiffCommentError('replies cannot be nested — reply to the thread\'s first comment');
173
+ if (!COMMENT_AUTHORS.includes(author)) throw new DiffCommentError('author must be "user" or "ask"');
174
+ const text = cleanBody(body);
175
+ const id = newCommentId();
176
+ const ts = now();
177
+ getDb();
178
+ prepare(`INSERT INTO diff_comments
179
+ (id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
180
+ body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at, parent_id)
181
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)`)
182
+ .run(id, parent.storeKey, parent.pipelineId, parent.projectKey, parent.path, parent.oldPath,
183
+ parent.side, parent.line, parent.lineText, text, author,
184
+ parent.resolved ? 1 : 0, parent.resolvedAt, ts, parent.id);
185
+ notify(parent.storeKey, parent.pipelineId);
186
+ return getDiffComment(id);
187
+ }
188
+
189
+ /** Toggle a THREAD (D2). Returns the updated root, or null when the id is unknown.
190
+ * @throws {DiffCommentError} when `id` is a reply — replies mirror their root. */
151
191
  export function setDiffCommentResolved(id, resolved = true) {
152
192
  const before = getDiffComment(id);
153
193
  if (!before) return null;
194
+ if (before.parentId) throw new DiffCommentError('replies cannot be resolved on their own — resolve the thread\'s first comment');
154
195
  const on = resolved !== false;
155
196
  getDb();
156
- prepare('UPDATE diff_comments SET resolved = ?, resolved_at = ? WHERE id = ?')
157
- .run(on ? 1 : 0, on ? now() : null, id);
197
+ prepare('UPDATE diff_comments SET resolved = ?, resolved_at = ? WHERE id = ? OR parent_id = ?')
198
+ .run(on ? 1 : 0, on ? now() : null, id, id);
158
199
  notify(before.storeKey, before.pipelineId);
159
200
  return getDiffComment(id);
160
201
  }
@@ -216,6 +257,11 @@ export function stampSentRunId(commentIds, pipelineId) {
216
257
  * Delete every comment of a run. NO transaction of its own — the CALLER owns it
217
258
  * (db.mjs tx() is not re-entrant and throws on nesting, and archivePipeline calls
218
259
  * this from inside its own tx). Used only by the archive path.
260
+ * @returns {number} rows the DELETE removed DIRECTLY. Since v29 a root takes its
261
+ * replies with it through the parent_id cascade, and SQLite does not count
262
+ * cascaded rows in `.changes` — so this UNDER-counts a run with threads and is
263
+ * not the number of comments that went away. No caller reads it; do not start
264
+ * without switching to a `SELECT count(*)` taken first.
219
265
  */
220
266
  export function deleteCommentsForRun(pipelineId) {
221
267
  getDb();
@@ -0,0 +1,75 @@
1
+ // The ONE reader of an agent .md's leading YAML frontmatter. Four private copies
2
+ // existed (agent-registry's description reader, workflows.mjs' and plugin-store's
3
+ // tools readers, workflow-export's fence matcher); the three READERS now share
4
+ // this. Deliberately NOT a YAML parser: subagent frontmatter is `key: value`
5
+ // single-line scalars (name / description / tools / model) — block scalars
6
+ // degrade to '' (the indicator is never stored), unknown keys ride `fields`.
7
+ import { openSync, readSync, closeSync } from 'node:fs';
8
+
9
+ /** Bytes read from the HEAD of an agent file. Every shipped frontmatter is < 2 KB;
10
+ * a fence that does not close inside the window reads as "no frontmatter" — the
11
+ * body (a 4–8 KB prompt) is never read by a frontmatter consumer. */
12
+ export const FRONTMATTER_HEAD_BYTES = 8192;
13
+
14
+ /** Leading fence: optional BOM, `---`, newline (LF or CRLF), inner YAML, newline,
15
+ * `---`, then end of line or end of text. m[1] = the inner YAML. */
16
+ export const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
17
+
18
+ const KEY_LINE_RE = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*(.*)$/;
19
+ const BLOCK_SCALAR_RE = /^[>|][+-]?$/;
20
+
21
+ function scalar(raw) {
22
+ const v = raw.trim();
23
+ if (BLOCK_SCALAR_RE.test(v)) return ''; // folded/literal indicator, not the text
24
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
25
+ return v.slice(1, -1).trim();
26
+ }
27
+ return v;
28
+ }
29
+
30
+ /**
31
+ * @param {unknown} text the file text (or its head)
32
+ * @returns {{name:string, description:string, tools:string[], model:string, fields:Record<string,string>}|null}
33
+ * null when the text carries no leading fence (or the fence never closes).
34
+ */
35
+ export function parseFrontmatter(text) {
36
+ if (typeof text !== 'string') return null;
37
+ const m = FRONTMATTER_RE.exec(text);
38
+ if (!m) return null;
39
+ const fields = {};
40
+ for (const line of m[1].split(/\r?\n/)) {
41
+ const kv = KEY_LINE_RE.exec(line);
42
+ if (!kv || Object.hasOwn(fields, kv[1])) continue; // continuation lines / repeats are skipped
43
+ fields[kv[1]] = scalar(kv[2]);
44
+ }
45
+ const tools = (fields.tools || '').split(',').map((s) => s.trim()).filter(Boolean);
46
+ return { name: fields.name || '', description: fields.description || '', tools, model: fields.model || '', fields };
47
+ }
48
+
49
+ /** Everything after the fence; the whole text when there is none. */
50
+ export function stripFrontmatter(text) {
51
+ const s = typeof text === 'string' ? text : '';
52
+ const m = FRONTMATTER_RE.exec(s);
53
+ return m ? s.slice(m[0].length) : s;
54
+ }
55
+
56
+ /**
57
+ * Read + parse the frontmatter of a file, touching at most `maxBytes` of its
58
+ * head. Synchronous (the registry loader is synchronous). Missing file,
59
+ * unreadable file, no fence ⇒ null — never throws.
60
+ * @param {string} path
61
+ * @param {number} [maxBytes]
62
+ */
63
+ export function readFrontmatterSync(path, maxBytes = FRONTMATTER_HEAD_BYTES) {
64
+ let fd = null;
65
+ try {
66
+ fd = openSync(path, 'r');
67
+ const buf = Buffer.alloc(maxBytes);
68
+ const n = readSync(fd, buf, 0, maxBytes, 0);
69
+ return parseFrontmatter(buf.subarray(0, n).toString('utf8'));
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ if (fd !== null) { try { closeSync(fd); } catch { /* ignore */ } }
74
+ }
75
+ }