@worca/app 1.3.0 → 1.4.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 (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
@@ -0,0 +1,207 @@
1
+ # src/core/graph/worca_script.py
2
+ # The `python` runtime harness (scripts-workbench spec §7) — the twin of
3
+ # script-child.mjs, one language over. Spawned as
4
+ # <python> -u worca_script.py <program.py>
5
+ # with the envelope on stdin.
6
+ #
7
+ # Imports NOTHING from worca and nothing outside the standard library: it runs
8
+ # with WORCA_HOME stripped, on whatever interpreter the probe found, and must
9
+ # parse and run on python 3.8 (no match, no X | Y unions, no removeprefix).
10
+ #
11
+ # Protocol: read ONE JSON envelope from stdin, load the program, call its
12
+ # main(api), write ONE JSON frame to the REAL stdout, exit 0. stdout is
13
+ # protocol-reserved (base spec §5.1 rule 2) at TWO levels:
14
+ # - the file descriptor: a private duplicate of fd 1 is kept for the frame and
15
+ # fd 1 itself is pointed at stderr, so a child process the program starts
16
+ # (subprocess.run([...]) with no capture, os.system) writes into the run log.
17
+ # Rebinding sys.stdout alone does not cover that: a child inherits the OS
18
+ # descriptor, and one stray line before the frame is "stdout is not JSON".
19
+ # - the python object: sys.stdout points at sys.stderr from the moment user code
20
+ # can run and is NEVER pointed back, so every print() goes through the utf-8
21
+ # stream configured below — including one made after main() returned (an atexit
22
+ # hook, a worker thread that outlives main): the frame handle is private.
23
+ # A non-zero exit means "crashed before the frame" and the parent reports
24
+ # "no result frame (exit N)".
25
+ import asyncio
26
+ import importlib.machinery
27
+ import importlib.util
28
+ import inspect
29
+ import json
30
+ import os
31
+ import sys
32
+ import traceback
33
+
34
+ # Never leave a __pycache__ beside a user's program — or inside the installed
35
+ # package, which is where the built-in `py` card's program lives. Process-local on
36
+ # purpose: PYTHONDONTWRITEBYTECODE in the environment would leak into every python
37
+ # tool the program starts (pytest, a build script) and change how THEY behave.
38
+ sys.dont_write_bytecode = True
39
+
40
+
41
+ def _reserve_stdout():
42
+ """Keep a private handle on the REAL stdout for the frame, then point fd 1 at stderr.
43
+
44
+ os.dup() returns a NON-inheritable descriptor (PEP 446), so no child process can
45
+ ever write into the frame; os.dup2(2, 1) makes fd 1 the run log for this process
46
+ and for everything it starts. Falls back to the plain object when the descriptors
47
+ are not there to duplicate (an embedded interpreter) — print() is still covered.
48
+ """
49
+ try:
50
+ sys.stdout.flush()
51
+ keep = os.dup(1)
52
+ os.dup2(2, 1)
53
+ return os.fdopen(keep, 'w', encoding='utf-8', newline='')
54
+ except (OSError, ValueError, AttributeError):
55
+ return sys.stdout
56
+
57
+
58
+ # The REAL stdout, reserved before anything can rebind or inherit it: the frame goes here.
59
+ _FRAME_OUT = _reserve_stdout()
60
+ # Appended by api.log(); carried on the frame even when main() then raises.
61
+ _LOGS = []
62
+
63
+ for _stream in (sys.stdin, sys.stderr):
64
+ # Windows consoles default to cp1252; the parent also exports PYTHONIOENCODING
65
+ # and PYTHONUTF8, this is the belt to that pair of braces. errors='replace' so
66
+ # an unprintable byte in a log line can never kill the execution.
67
+ if hasattr(_stream, 'reconfigure'):
68
+ try:
69
+ _stream.reconfigure(encoding='utf-8', errors='replace')
70
+ except (ValueError, OSError):
71
+ pass
72
+
73
+
74
+ class Api(dict):
75
+ """The api bag: api.params and api['params'] are the same thing (spec §7)."""
76
+
77
+ def __getattr__(self, name):
78
+ try:
79
+ return self[name]
80
+ except KeyError:
81
+ # Only the MESSAGE reaches the run (`script "k": <message>`), so a bare name
82
+ # would read as `script "k": plan`. Say what is missing and what is there.
83
+ raise AttributeError('no "%s" here (has: %s)' % (name, ', '.join(sorted(str(k) for k in self)) or 'nothing')) from None
84
+
85
+ def __setattr__(self, name, value):
86
+ self[name] = value
87
+
88
+ def __delattr__(self, name):
89
+ try:
90
+ del self[name]
91
+ except KeyError:
92
+ raise AttributeError(name) from None
93
+
94
+
95
+ def _wrap(value):
96
+ """Envelope JSON -> Api all the way down, so api.outputs.out.path reads naturally."""
97
+ if isinstance(value, dict):
98
+ return Api((key, _wrap(item)) for key, item in value.items())
99
+ if isinstance(value, list):
100
+ return [_wrap(item) for item in value]
101
+ return value
102
+
103
+
104
+ def _log(level, msg):
105
+ _LOGS.append({'level': str(level), 'msg': str(msg)})
106
+
107
+
108
+ def _load(path):
109
+ """Load the program as its own module, with its directory first on sys.path so
110
+ a script's sibling imports work (the node runtime gets that for free)."""
111
+ directory = os.path.dirname(os.path.abspath(path))
112
+ if directory not in sys.path:
113
+ sys.path.insert(0, directory)
114
+ # An EXPLICIT source loader: left to itself spec_from_file_location() picks the
115
+ # loader by suffix and answers None for anything that is not .py, and the sidecar
116
+ # only asks `file` to be a plain basename.
117
+ loader = importlib.machinery.SourceFileLoader('worca_user_script', path)
118
+ spec = importlib.util.spec_from_file_location('worca_user_script', path, loader=loader)
119
+ if spec is None or spec.loader is None:
120
+ raise ImportError('cannot load script module: ' + str(path))
121
+ module = importlib.util.module_from_spec(spec)
122
+ sys.modules[spec.name] = module
123
+ spec.loader.exec_module(module)
124
+ return module
125
+
126
+
127
+ async def _resolve(value):
128
+ return await value
129
+
130
+
131
+ def _run(envelope, path):
132
+ api = Api(
133
+ inputs=_wrap(envelope.get('inputs') or {}),
134
+ outputs=_wrap(envelope.get('outputs') or {}),
135
+ params=_wrap(envelope.get('params') or {}),
136
+ ctx=_wrap(envelope.get('ctx') or {}),
137
+ verdictPath=envelope.get('verdictPath'),
138
+ node=_wrap(envelope.get('node') or {}),
139
+ execution=_wrap(envelope.get('execution') or {}),
140
+ log=_log,
141
+ )
142
+ module = _load(path)
143
+ entry = getattr(module, 'main', None)
144
+ if not callable(entry):
145
+ raise TypeError('script module has no callable main(api): ' + str(path))
146
+ result = entry(api)
147
+ if inspect.isawaitable(result):
148
+ result = asyncio.run(_resolve(result))
149
+ returned = result if isinstance(result, dict) else {}
150
+ frame = {'ok': True, 'logs': _LOGS}
151
+ if isinstance(returned.get('outputs'), dict):
152
+ frame['outputs'] = returned['outputs']
153
+ if 'verdict' in returned:
154
+ frame['verdict'] = returned['verdict']
155
+ if isinstance(returned.get('summary'), str):
156
+ frame['summary'] = returned['summary']
157
+ return frame
158
+
159
+
160
+ def _message(err):
161
+ """str(err), except where python's own str() says nothing useful on its own."""
162
+ text = str(err)
163
+ if not text:
164
+ return err.__class__.__name__
165
+ if isinstance(err, KeyError): # str(KeyError('plan')) is just "'plan'"
166
+ return 'KeyError: ' + text
167
+ return text
168
+
169
+
170
+ def _encode(frame):
171
+ """ONE line of pure-ASCII JSON. ensure_ascii (the default) keeps the frame ASCII
172
+ whatever the console encoding is; the parent's JSON.parse restores every escape.
173
+ A returned set / bytes / datetime / Path, a NaN (which json would happily print
174
+ as the non-JSON token NaN) or a nesting json gives up on (RecursionError — not a
175
+ ValueError) is reported the way script-child.mjs reports it — as a frame —
176
+ instead of dying with no frame at all."""
177
+ try:
178
+ return json.dumps(frame, allow_nan=False)
179
+ except Exception as err:
180
+ return json.dumps({'ok': False, 'logs': _LOGS,
181
+ 'error': {'message': 'frame is not serializable: ' + (str(err) or err.__class__.__name__)}})
182
+
183
+
184
+ def main():
185
+ try:
186
+ raw = sys.stdin.read()
187
+ envelope = json.loads(raw) if raw.strip() else {}
188
+ path = sys.argv[1] if len(sys.argv) > 1 else None
189
+ if not path:
190
+ raise ValueError('worca_script.py: no program file argument')
191
+ sys.stdout = sys.stderr # stdout is protocol-reserved: prints go to the run log
192
+ frame = _run(envelope, path)
193
+ except Exception as err: # a user's sys.exit() is SystemExit and is left to propagate:
194
+ frame = {'ok': False, 'logs': _LOGS, # no frame, non-zero exit, "no result frame (exit N)" upstream
195
+ 'error': {'message': _message(err), 'stack': traceback.format_exc()}}
196
+ # sys.stdout is NOT pointed back at the frame handle — not here, not ever. User
197
+ # code is not over when main() returns: an atexit hook, a finalizer or a worker
198
+ # thread that outlives main (the exit below waits for a non-daemon one) can still
199
+ # print, and one byte after the frame is "stdout is not JSON" for a card that
200
+ # succeeded. Only this function ever writes to _FRAME_OUT.
201
+ _FRAME_OUT.write(_encode(frame))
202
+ _FRAME_OUT.flush()
203
+ sys.exit(0)
204
+
205
+
206
+ if __name__ == '__main__':
207
+ main()
@@ -19,6 +19,7 @@ import { GUARDRAIL_PRESETS, GUARDRAIL_LEVELS, sanitizeGuardrails } from './guard
19
19
  // store stamps code:'REFERENCED' at the throw site, and the server ALSO matches
20
20
  // structurally (err.name === 'ReferencedError' || err.code === 'REFERENCED').
21
21
  import { ReferencedError } from './plugin-workflows.mjs';
22
+ import { policyGuardrailSets } from './policy/cache.mjs';
22
23
 
23
24
  export { ReferencedError };
24
25
 
@@ -79,9 +80,24 @@ function readRaw(id) {
79
80
  */
80
81
  export async function readGuardrailSet(id) {
81
82
  if (isBuiltinGuardrailSetId(id)) return builtinSet(id);
83
+ // Team policy sets (team-policy design §8): virtual, read-only, `gp:<id>`, resolved from the
84
+ // discovery cache at read time — so a paused run that pinned one re-reads its latest
85
+ // definition on resume, and a vanished one takes the existing fail-open path.
86
+ if (isPolicyGuardrailSetId(id)) return policyGuardrailSet(id);
82
87
  return readRaw(id);
83
88
  }
84
89
 
90
+ /** `gp:<id>` — a guardrail set shipped by a team policy (never a row; `:` is not a row-id character). */
91
+ export function isPolicyGuardrailSetId(id) { return typeof id === 'string' && /^gp:[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id); }
92
+ function policyGuardrailSet(id) {
93
+ const hit = policyGuardrailSets().find((s) => s.id.toLowerCase() === id.toLowerCase());
94
+ return hit ? { ...hit, settings: sanitizeGuardrails(hit.settings) } : null;
95
+ }
96
+ /** Every policy set this machine has cached (Guardrails list rows with a policy badge). */
97
+ export function listPolicyGuardrailSets() {
98
+ return policyGuardrailSets().map((s) => ({ ...s, settings: sanitizeGuardrails(s.settings) }));
99
+ }
100
+
85
101
  /**
86
102
  * List USER sets (NOT the built-ins — callers prepend listBuiltinGuardrailSets()),
87
103
  * newest first by createdAt. Empty store => []. Never throws.
@@ -0,0 +1,108 @@
1
+ // src/core/human-backfill.mjs
2
+ // One-shot RUN-LEVEL human-hours backfill for runs recorded before schema v33 (money-saved
3
+ // design §6.1). Synchronous (it runs inside the migration ladder), best-effort per run: a
4
+ // missing file, an unreadable JSON or an unknown store root contributes 0 and never throws.
5
+ // Steps stay NULL — only the run row is credited. Never re-runs on a credited run, and never
6
+ // touches a run whose steps already carry an estimate (the step path owns those).
7
+ import { readdirSync, readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { projectStorePath, workspaceStorePath } from './store.mjs';
10
+ import { estimateStepHours, proseWords, jsonItems, roundHours } from '../shared/human-estimate.mjs';
11
+
12
+ const TERMINAL = "('done','error','stopped')"; // `interrupted` and `paused` are resumable (stats.mjs buckets them together): the step path owns them
13
+ const SKIP_JSON = new Set(['results.json', 'run.json', 'memory.json']);
14
+ const MD_DIR_RE = /^(plans|reviews)\//;
15
+ const VERSION_RE = /-v(\d+)\.md$/i;
16
+
17
+ function readTextSafe(path) { try { return readFileSync(path, 'utf8'); } catch { return null; } }
18
+
19
+ /** The pipeline dir under <root>/pipelines whose name ends with `-<id>` (the run-dir naming). */
20
+ function pipelineDirFor(root, id, cache) {
21
+ if (!cache.has(root)) {
22
+ let names = [];
23
+ try { names = readdirSync(join(root, 'pipelines')); } catch { /* no store yet */ }
24
+ cache.set(root, names);
25
+ }
26
+ const name = cache.get(root).find((n) => n.endsWith(`-${id}`));
27
+ return name ? join(root, 'pipelines', name) : null;
28
+ }
29
+
30
+ const producer = (over) => ({ nodeKind: 'agent', agent: { runnerType: 'producer' }, cycle: 1, code: null, outputs: [], reads: null, ...over });
31
+
32
+ /** db.mjs's hasSqliteTable is not exported; a hand-seeded upgrade fixture may lack a table. */
33
+ function hasTable(db, name) {
34
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
35
+ }
36
+
37
+ function defaultRootFor(row) {
38
+ try {
39
+ return row.target === 'workspace' && row.workspace_key ? workspaceStorePath(row.workspace_key) : projectStorePath(row.project_key);
40
+ } catch { return null; } // no resolvable home (node:test without WORCA_HOME): credit nothing
41
+ }
42
+
43
+ /**
44
+ * @param {import('node:sqlite').DatabaseSync} db
45
+ * @param {{rootFor?:(row:object)=>string|null}} [opts]
46
+ * @returns {{runs:number, credited:number}}
47
+ */
48
+ export function backfillHumanHours(db, { rootFor = defaultRootFor } = {}) {
49
+ if (!hasTable(db, 'artifacts') || !hasTable(db, 'reviews')) return { runs: 0, credited: 0 };
50
+ const rows = db.prepare(`SELECT id, project_key, workspace_key, target FROM pipelines
51
+ WHERE status IN ${TERMINAL} AND human_hours = 0`).all();
52
+ const hasStepEstimate = db.prepare('SELECT 1 AS n FROM pipeline_steps WHERE pipeline_id = ? AND human_hours IS NOT NULL LIMIT 1');
53
+ const reviews = db.prepare('SELECT verdict FROM reviews WHERE pipeline_id = ? ORDER BY rowid'); // every persisted verdict (writeReview)
54
+ const artifacts = db.prepare('SELECT kind, rel_path FROM artifacts WHERE pipeline_id = ? ORDER BY rowid'); // insertion order: a plan, its versions, then the reviews
55
+ const update = db.prepare('UPDATE pipelines SET human_hours = ? WHERE id = ?');
56
+ const dirCache = new Map();
57
+ let credited = 0;
58
+ for (const row of rows) {
59
+ try {
60
+ if (hasStepEstimate.get(row.id)) continue;
61
+ const root = rootFor(row);
62
+ if (!root) continue;
63
+ const pdir = pipelineDirFor(root, row.id, dirCache);
64
+ const pseudo = [];
65
+ let diffLines = 0;
66
+ // code
67
+ const results = pdir ? readTextSafe(join(pdir, 'results.json')) : null;
68
+ if (results) {
69
+ try {
70
+ const s = JSON.parse(results)?.summary || {};
71
+ const ins = s.linesAdded | 0; const del = s.linesRemoved | 0;
72
+ diffLines = ins + del;
73
+ pseudo.push(producer({ code: { files: (s.filesNew | 0) + (s.filesChanged | 0), insertions: ins, deletions: del } }));
74
+ } catch { /* unreadable summary → no code credit */ }
75
+ }
76
+ // prose + json artifacts
77
+ for (const a of artifacts.all(row.id)) {
78
+ const rel = String(a.rel_path || '');
79
+ if (rel.endsWith('.md') && MD_DIR_RE.test(rel)) {
80
+ const text = readTextSafe(join(root, ...rel.split('/')));
81
+ if (text == null) continue;
82
+ const m = VERSION_RE.exec(rel);
83
+ // -vN is the (N−1)-th revision: the refiner's k-th execution mints -v(k+1) and the step
84
+ // path credits it at cycle k (spec §6.1), so the decay here is 0.5^(N−2), never 0.5^(N−1).
85
+ pseudo.push(producer({ cycle: m ? Math.max(1, Number(m[1]) - 1) : 1, outputs: [{ type: 'md', words: proseWords(text), revision: !!m }] }));
86
+ } else if (rel.endsWith('.json') && pdir && !SKIP_JSON.has(rel) && !rel.includes('/')) { // dir-relative JSON the run indexed (decomposition.json)
87
+ const text = readTextSafe(join(pdir, rel));
88
+ if (text == null) continue;
89
+ let items = 0; try { items = jsonItems(JSON.parse(text)); } catch { /* 0 */ }
90
+ pseudo.push(producer({ outputs: [{ type: 'json', items }] }));
91
+ }
92
+ }
93
+ // verdict JSON: the product persists every per-cycle verdict in reviews.verdict (writeReview);
94
+ // the *-review-cycleN.json file the agent wrote is transient scratch and never an artifacts row.
95
+ let verdicts = 0;
96
+ for (const r of reviews.all(row.id)) {
97
+ verdicts += 1;
98
+ let items = 0; try { items = jsonItems(JSON.parse(r.verdict)); } catch { /* 0 */ }
99
+ pseudo.push(producer({ outputs: [{ type: 'json', items }] }));
100
+ }
101
+ // one verifier read of the full diff when a verdict was written — no agent key involved (rule 6, cycle 1 only)
102
+ if (diffLines > 0 && verdicts > 0) pseudo.push(producer({ agent: { runnerType: 'verifier' }, reads: { diffLines, words: 0 } }));
103
+ const hours = roundHours(pseudo.reduce((sum, e) => sum + estimateStepHours(e).hours, 0));
104
+ if (hours > 0) { update.run(hours, row.id); credited += 1; }
105
+ } catch { /* best-effort per run */ }
106
+ }
107
+ return { runs: rows.length, credited };
108
+ }
@@ -0,0 +1,17 @@
1
+ // src/core/human-rate.mjs
2
+ // The rate that prices human hours (money-saved design §8): the developer's stored setting wins
3
+ // when set, then the team policy's `cost.humanRateUsd` default, then 35. Read fresh per call.
4
+ import { humanRateUsdPerHour, DEFAULT_HUMAN_RATE_USD } from './settings.mjs';
5
+ import { teamDefault } from './policy/cache.mjs';
6
+
7
+ /** @param {string|null} [projectDir] the project whose policy home applies (null: no policy lookup) */
8
+ export function effectiveHumanRateUsd(projectDir = null) {
9
+ const local = humanRateUsdPerHour();
10
+ if (local != null) return local;
11
+ if (projectDir) {
12
+ let team;
13
+ try { team = teamDefault(projectDir, 'cost.humanRateUsd'); } catch { team = undefined; } // a policy read must never break a metrics page
14
+ if (typeof team === 'number' && Number.isFinite(team) && team > 0) return team;
15
+ }
16
+ return DEFAULT_HUMAN_RATE_USD;
17
+ }
@@ -8,10 +8,14 @@ export const INDEX_THEME_ANCHOR = '<html lang="en" data-theme="system">';
8
8
  /**
9
9
  * @param {string} html the on-disk ui/public/index.html
10
10
  * @param {'system'|'light'|'dark'} theme
11
+ * @param {'simple'|'advanced'|'expert'} [level] always one of UI_LEVELS (the server resolves it)
11
12
  * @returns {string}
12
13
  * @throws {Error} when the anchor is not present (index.html drifted)
13
14
  */
14
- export function renderIndexHtml(html, theme) {
15
+ export function renderIndexHtml(html, theme, level) {
15
16
  if (!html.includes(INDEX_THEME_ANCHOR)) throw new Error('index.html theme anchor missing');
16
- return html.replace(INDEX_THEME_ANCHOR, `<html lang="en" data-theme="${theme}">`);
17
+ // `level` (docs/ui-levels.md) rides the same anchor so the first paint already hides what the
18
+ // interface mode hides. Omitted ⇒ no attribute, and the stylesheet then gates nothing.
19
+ const lvl = level ? ` data-level="${level}"` : '';
20
+ return html.replace(INDEX_THEME_ANCHOR, `<html lang="en" data-theme="${theme}"${lvl}>`);
17
21
  }
@@ -0,0 +1,112 @@
1
+ // src/core/memory-defrag-model.mjs
2
+ // Settings › Memory: which model (and effort) a Memory defragment run's agent uses. Pure
3
+ // functions — the callers read the setting (settings.mjs#memoryDefragModel) and the catalog
4
+ // (config.mjs#listModels) and hand them in, so this module imports nothing but the built-in
5
+ // workflow constant.
6
+ //
7
+ // Precedence, resolved ONCE at run start (orchestrator.mjs _defragAgentPair):
8
+ // 1. a pair named explicitly at start — the run's `claude.model` (+ `claude.effort`): the CLI's
9
+ // --model (verbatim, like --model everywhere), a CLI-made schedule's stored model, or the
10
+ // `model`/`effort` of a POST /api/run body starting a defragment run (checkStartPair).
11
+ // 2. the stored setting, validated against the run's project catalog.
12
+ // 3. nothing: the node layers resolve exactly as before (a project's own node pick, a team
13
+ // default, then the built-in template's model).
14
+ // The pair rule holds throughout: an effort travels with its model, so an effort without a model
15
+ // is ignored and no lower layer's effort ever rides under a model it was not chosen for.
16
+
17
+ import { GRAPH_MEMORY_DEFRAG_WORKFLOW } from './graph/builtin-workflows.mjs';
18
+
19
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
20
+
21
+ /**
22
+ * @param {{explicit?:{model?:string, effort?:string}, stored?:{model?:string|null, effort?:string|null},
23
+ * models?:Array<{id:string, efforts?:string[]}>}} o `models` = the catalog the run resolves against
24
+ * @returns {{model:string|null, effort:string|null, source:'explicit'|'setting'|'default', warning:string|null}}
25
+ * `model: null` = no pair (today's resolution). `warning` = a stored setting that did not survive
26
+ * the catalog check — the run degrades, it is never refused. It names the problem only: the
27
+ * caller appends what the run uses instead (agentPairText, once the graph is resolved).
28
+ */
29
+ export function resolveDefragModel({ explicit = {}, stored = {}, models = [] } = {}) {
30
+ const exModel = str(explicit && explicit.model);
31
+ if (exModel) return { model: exModel, effort: str(explicit.effort), source: 'explicit', warning: null };
32
+ const model = str(stored && stored.model);
33
+ if (!model) return { model: null, effort: null, source: 'default', warning: null };
34
+ const hit = (Array.isArray(models) ? models : [])
35
+ .find((m) => m && typeof m.id === 'string' && m.id.toLowerCase() === model.toLowerCase());
36
+ if (!hit) {
37
+ return {
38
+ model: null, effort: null, source: 'default',
39
+ warning: `Memory defragment model "${model}" (Settings › Memory) is not in this project's model catalog`,
40
+ };
41
+ }
42
+ const effort = str(stored.effort);
43
+ if (effort && !(Array.isArray(hit.efforts) && hit.efforts.includes(effort))) {
44
+ return {
45
+ model: hit.id, effort: null, source: 'setting',
46
+ warning: `Memory defragment effort "${effort}" (Settings › Memory) is not offered by ${hit.id}`,
47
+ };
48
+ }
49
+ return { model: hit.id, effort, source: 'setting', warning: null };
50
+ }
51
+
52
+ /**
53
+ * The tail of a degrade warning — what the run's agent nodes resolved to INSTEAD (a project's own
54
+ * pick, a team default or the template's model), so the log never claims a fallback the run did
55
+ * not take: `claude-fable-5-1 · max`, `claude-sonnet-5 at its default effort`.
56
+ * @param {Record<string, {kind?:string, model?:string, effort?:string}>} nodes resolveGraph's per-node table
57
+ */
58
+ export function agentPairText(nodes) {
59
+ const seen = [];
60
+ for (const nc of Object.values(nodes && typeof nodes === 'object' ? nodes : {})) {
61
+ if (!nc || nc.kind !== 'agent') continue;
62
+ const text = nc.model ? `${nc.model}${nc.effort ? ` · ${nc.effort}` : ' at its default effort'}` : 'the CLI default model';
63
+ if (!seen.includes(text)) seen.push(text);
64
+ }
65
+ return seen.join(', ') || 'the CLI default model';
66
+ }
67
+
68
+ /**
69
+ * POST /api/run: the pair a defragment run names AT START (`body.model` / `body.effort`) — tier 1,
70
+ * above the setting. Checked against the run's project catalog like any model a user picks: the id
71
+ * comes back in the catalog's casing and the effort must be one the entry offers; an effort without
72
+ * a model is refused (it would mean nothing). `models: null` skips the catalog check — a scheduled
73
+ * ticket firing, whose request was checked when it was scheduled (then taken verbatim, like --model).
74
+ * @param {{model?:unknown, effort?:unknown}} body
75
+ * @param {Array<{id:string, efforts?:string[]}>|null} models
76
+ * @returns {{pair: ({model:string, effort:(string|null)}|null)} | {error: string}}
77
+ */
78
+ export function checkStartPair({ model, effort } = {}, models = null) {
79
+ const blank = (v) => v === undefined || v === null || (typeof v === 'string' && !v.trim());
80
+ if (blank(model)) return blank(effort) ? { pair: null } : { error: 'effort needs a model — an effort without a model means nothing' };
81
+ if (typeof model !== 'string' || model.length > 200) return { error: 'model must be a catalog model id' };
82
+ if (!blank(effort) && typeof effort !== 'string') return { error: 'effort must be a string' };
83
+ let id = model.trim();
84
+ const eff = blank(effort) ? null : effort.trim();
85
+ if (Array.isArray(models)) {
86
+ const hit = models.find((m) => m && typeof m.id === 'string' && m.id.toLowerCase() === id.toLowerCase());
87
+ if (!hit) return { error: `unknown model "${id}"` };
88
+ id = hit.id;
89
+ if (eff && !(Array.isArray(hit.efforts) && hit.efforts.includes(eff))) return { error: `${id} does not offer effort "${eff}"` };
90
+ }
91
+ return { pair: { model: id, effort: eff } };
92
+ }
93
+
94
+ /** What "(default)" means in Settings › Memory: the built-in template's own agent model. */
95
+ export function defragDefaultModel() {
96
+ const agent = GRAPH_MEMORY_DEFRAG_WORKFLOW.nodes.find((n) => n.kind === 'agent');
97
+ return (agent && agent.config && typeof agent.config.model === 'string' && agent.config.model) || null;
98
+ }
99
+
100
+ /**
101
+ * GET /api/workflows/wf_memory_defrag, as every start surface should show it: with a resolved
102
+ * pair the template carries `pinnedAgentModel`, which node-tunables.mjs turns into LOCKED
103
+ * model/effort rows (New pipeline's agent rows, an Ask card's lane) — the run uses the pair
104
+ * whatever the project picked, so an editable model there would be a control that lies. The
105
+ * deep-frozen constant is never mutated: a shallow copy gains one key.
106
+ * @param {object} wf the workflow as assertRunnableWorkflow returned it
107
+ * @param {{model:string|null, effort?:string|null}|null} pair resolveDefragModel's answer
108
+ */
109
+ export function defragWorkflowView(wf, pair) {
110
+ if (!wf || !pair || !str(pair.model)) return wf;
111
+ return { ...wf, pinnedAgentModel: { model: str(pair.model), effort: str(pair.effort), source: 'settings' } };
112
+ }
@@ -210,7 +210,7 @@ export async function readMemory(root, scope, name) {
210
210
 
211
211
  // ── .state counters ──────────────────────────────────────────────────────────
212
212
  const stateFile = (root, scope) => join(root, '.state', `${scopeKey(scope)}.json`);
213
- const EMPTY_STATE = Object.freeze({ writesSinceDefrag: 0, lastWriteAt: null, lastDefragAt: null, lastDefragRunId: null });
213
+ const EMPTY_STATE = Object.freeze({ writesSinceDefrag: 0, lastWriteAt: null, lastDefragAt: null, lastDefragRunId: null, failedWrites: 0, lastFailedAt: null, lastFailedRunId: null });
214
214
  export async function readScopeState(root, scope) {
215
215
  const text = await readTextMaybe(stateFile(root, scope));
216
216
  if (text === null) return { ...EMPTY_STATE };
@@ -339,8 +339,9 @@ export async function removeMemory(root, scope, name, { source, now, snapshot =
339
339
  }
340
340
 
341
341
  // ── the agent-facing pointer block (§4.2, native-rules revision) ─────
342
- // The bodies reach the agent through Claude Code's own `.claude/rules` loader (the
343
- // mount lives inside every spawn's cwd — memory-sync.mjs MEMORY_RULES_REL); this block
342
+ // The bodies reach the agent through Claude Code's own `.claude/rules` loader (the rules copy
343
+ // lives inside every spawn's cwd (memory-sync.mjs MEMORY_RULES_REL) and is READ-ONLY there; the
344
+ // dir lines name the WRITABLE copy (memoryWorkPath)); this block
344
345
  // only says WHERE memory lives and WHAT belongs there — the WRITE TRIGGER, the worth-a-file
345
346
  // categories and the anti-list are what decide whether a run records anything at all, so they
346
347
  // live in the intro rather than in each agent body. One `Label — /abs/dir:` line per
@@ -348,10 +349,12 @@ export async function removeMemory(root, scope, name, { source, now, snapshot =
348
349
  // intro must stay ONE line and nothing may follow the dir lines inside the block.
349
350
  export const MEMORY_BLOCK_HEADING = '## Worca memory';
350
351
  export const MEMORY_BLOCK_INTRO =
351
- 'Durable rules, preferences and traps kept across runs and chats. Claude Code loads them into your context ' +
352
- 'from the memory directories below (a file with `paths` loads when you read a matching file), so never search ' +
353
- 'for them (the built-in Explore and Plan sub-agents do not load them — read the files there if you are one). ' +
354
- 'WRITE TRIGGER: write a file there only when what you learned (a) cost you a cycle, or would have cost the next agent one, ' +
352
+ 'Durable rules, preferences and traps kept across runs and chats. Claude Code has already loaded them into your context as rules ' +
353
+ '(a file with `paths` loads when you read a matching file), so never search for them (the built-in Explore and Plan sub-agents ' +
354
+ 'do not load them — read the files in the directories below if you are one). The directories below hold the WRITABLE copy of ' +
355
+ 'those files — worca syncs them back to its store after your turn; the read-only copy under the cwd\'s `.claude/rules/worca` is ' +
356
+ 'Claude Code\'s own and a write there is refused as a sensitive path, so never write there. ' +
357
+ 'WRITE TRIGGER: write a file into the directories below only when what you learned (a) cost you a cycle, or would have cost the next agent one, ' +
355
358
  'or (b) contradicted what you assumed when you started — AND will still be true next month. Worth a file: ' +
356
359
  'a trap (behaves differently from how it reads); a verification recipe (the exact command / env var / ' +
357
360
  'fixture-regeneration step that proves work here is correct); an invariant or contract the code depends ' +
@@ -375,7 +378,7 @@ export function renderMemoryBlock(sections) {
375
378
  }
376
379
 
377
380
  // ── health (§8) ──────────────────────────────────────────────────────────────
378
- export const MEMORY_LEVELS = Object.freeze(['fresh', 'ok', 'due', 'overdue']);
381
+ export const MEMORY_LEVELS = Object.freeze(['fresh', 'ok', 'due', 'overdue', 'failing']);
379
382
  const DEFAULT_DEFRAG = Object.freeze({ writes: 10, files: 30, bytesPct: 60, alwaysOnBytes: 16384 });
380
383
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
381
384
  const names = (list) => `${list.slice(0, 3).map((e) => `${e.name}.md`).join(', ')}${list.length > 3 ? ', …' : ''}`;
@@ -390,6 +393,9 @@ const names = (list) => `${list.slice(0, 3).map((e) => `${e.name}.md`).join(', '
390
393
  * Native rules: every file WITHOUT `paths` is loaded into every agent's context at launch —
391
394
  * `alwaysOnBytes` is that cost (the figure the old 4 KB index cap used to bound). A path-scoped
392
395
  * file costs nothing until a matching file is read, so it is excluded from it.
396
+ * failing = the scope's last recorded write ATTEMPT by a run failed (failedWrites > 0 and no store
397
+ * write newer than lastFailedAt); it outranks every other level, fresh included, and a defragment
398
+ * resets the counters.
393
399
  */
394
400
  export function memoryHealth(entries, state, caps) {
395
401
  const list = Array.isArray(entries) ? entries : [];
@@ -412,27 +418,73 @@ export function memoryHealth(entries, state, caps) {
412
418
  const overBudget = budget > 0 && bytes * 100 >= T.bytesPct * budget;
413
419
  const pct = budget > 0 ? Math.floor((bytes * 100) / budget) : 0;
414
420
  const writes = Number(st.writesSinceDefrag) || 0;
415
- const reasons = [];
421
+ const failedWrites = Number(st.failedWrites) || 0;
422
+ // A store write (any writer) newer than the last failure means the path works again; the COUNT
423
+ // stays as a reason until a defragment resets it. A count with no timestamp is still loud.
424
+ const failing = failedWrites > 0 && !(st.lastWriteAt && st.lastFailedAt && st.lastWriteAt > st.lastFailedAt);
425
+ const fileReasons = [];
416
426
  if (files > 0) {
417
- if (writes >= T.writes) reasons.push(`${plural(writes, 'memory write')} since the last defragment (due at ${T.writes})`);
418
- if (files >= T.files) reasons.push(`${plural(files, 'file')} in this scope (due at ${T.files})`);
419
- if (overBudget) reasons.push(`${pct}% of the scope's byte budget in use (due at ${T.bytesPct}%)`);
420
- if (oversized.length) reasons.push(`${plural(oversized.length, 'file')} over the ${soft}-byte soft cap: ${names(oversized)}`);
421
- if (overHard.length) reasons.push(`${plural(overHard.length, 'file')} over the ${hard}-byte hard cap — runs cannot update them: ${names(overHard)}`);
422
- if (invalid.length) reasons.push(`${plural(invalid.length, 'file')} without frontmatter — added by hand? worca still serves them; a defragment rewrites them: ${names(invalid)}`);
423
- if (alwaysOnBytes >= T.alwaysOnBytes) reasons.push(`${alwaysOnBytes} bytes of memory load into the context of every agent that mounts this scope (${plural(alwaysOn.length, 'file')} without paths; due at ${T.alwaysOnBytes})`);
427
+ if (writes >= T.writes) fileReasons.push(`${plural(writes, 'memory write')} since the last defragment (due at ${T.writes})`);
428
+ if (files >= T.files) fileReasons.push(`${plural(files, 'file')} in this scope (due at ${T.files})`);
429
+ if (overBudget) fileReasons.push(`${pct}% of the scope's byte budget in use (due at ${T.bytesPct}%)`);
430
+ if (oversized.length) fileReasons.push(`${plural(oversized.length, 'file')} over the ${soft}-byte soft cap: ${names(oversized)}`);
431
+ if (overHard.length) fileReasons.push(`${plural(overHard.length, 'file')} over the ${hard}-byte hard cap — runs cannot update them: ${names(overHard)}`);
432
+ if (invalid.length) fileReasons.push(`${plural(invalid.length, 'file')} without frontmatter — added by hand? worca still serves them; a defragment rewrites them: ${names(invalid)}`);
433
+ if (alwaysOnBytes >= T.alwaysOnBytes) fileReasons.push(`${alwaysOnBytes} bytes of memory load into the context of every agent that mounts this scope (${plural(alwaysOn.length, 'file')} without paths; due at ${T.alwaysOnBytes})`);
424
434
  }
425
- const level = files === 0 ? 'fresh'
435
+ const reasons = [...fileReasons];
436
+ if (failedWrites > 0) {
437
+ reasons.push(`${plural(failedWrites, 'memory write')} by runs failed since the last defragment` +
438
+ `${st.lastFailedRunId ? ` — last in run ${st.lastFailedRunId}` : ''}; that run's History detail carries the reason`);
439
+ }
440
+ const level = failing ? 'failing'
441
+ : files === 0 ? 'fresh'
426
442
  : (writes >= 2 * T.writes || overHard.length || alwaysOnBytes >= 2 * T.alwaysOnBytes) ? 'overdue'
427
- : reasons.length ? 'due' : 'ok';
443
+ : fileReasons.length ? 'due' : 'ok';
428
444
  return {
429
445
  files, bytes, oversized: oversized.length, overHard: overHard.length, invalidFrontmatter: invalid.length,
430
446
  alwaysOnBytes, alwaysOnFiles: alwaysOn.length,
431
447
  writesSinceDefrag: writes, lastWriteAt: st.lastWriteAt, lastDefragAt: st.lastDefragAt, lastDefragRunId: st.lastDefragRunId,
448
+ failedWrites, lastFailedAt: st.lastFailedAt, lastFailedRunId: st.lastFailedRunId,
432
449
  level, reasons,
433
450
  };
434
451
  }
435
452
 
453
+ // ── the defragmenter's brief (§7) ────────────────────────────────────────────
454
+ export const DEFRAG_BRIEF_HEADING = '## Memory health';
455
+
456
+ /**
457
+ * Pure. The section a Memory defragment run appends to its task document: WHY the scope is
458
+ * flagged (memoryHealth's own reasons, verbatim — the same lines Settings → Memory shows) and the
459
+ * budgets a finished defragment has to meet. Without it the agent tidies topics and leaves the
460
+ * scope exactly as `due` as it found it: the always-on budget is a property of the whole scope,
461
+ * which no single file shows. A healthy scope still gets the budgets, so a defragment never
462
+ * breaks one. Thresholds resolve exactly as memoryHealth resolves them; byte-stable.
463
+ * @param {ReturnType<typeof memoryHealth>} health
464
+ * @param {object} [caps] memoryCaps()
465
+ */
466
+ export function renderDefragBrief(health, caps) {
467
+ const h = health && typeof health === 'object' ? health : memoryHealth([], null, caps);
468
+ const T = { ...DEFAULT_DEFRAG, ...(caps?.defrag && typeof caps.defrag === 'object' ? caps.defrag : {}) };
469
+ const soft = caps?.softBytesPerFile ?? 8192;
470
+ const hard = caps?.hardBytesPerFile ?? 32768;
471
+ const maxFiles = caps?.maxFilesPerScope ?? 50;
472
+ const reasons = Array.isArray(h.reasons) ? h.reasons : [];
473
+ const lines = ['', DEFRAG_BRIEF_HEADING, ''];
474
+ if (reasons.length) {
475
+ lines.push(`Level: ${h.level}. worca flags this scope for the reasons below — the defragment is finished only when none of them still holds ` +
476
+ '(a write-count or failed-write reason clears by itself when this run ends; every other one needs your edits):', '');
477
+ for (const r of reasons) lines.push(`- ${flattenLine(r)}`);
478
+ } else {
479
+ lines.push(`Level: ${h.level}. No threshold is crossed — keep it that way.`);
480
+ }
481
+ lines.push('',
482
+ `Budgets for this scope: always-on memory (files WITHOUT \`paths\` — loaded into every agent's context) under ${T.alwaysOnBytes} bytes — ` +
483
+ `now ${h.alwaysOnBytes} in ${plural(h.alwaysOnFiles, 'file')}; fewer than ${T.files} files — now ${h.files}; ` +
484
+ `each file under ${soft} bytes (hard cap ${hard}); all files together under ${T.bytesPct}% of ${maxFiles} × ${soft} bytes — now ${h.bytes}.`);
485
+ return `${lines.join('\n')}\n`;
486
+ }
487
+
436
488
  /** Everything a scope view or route needs in one read: the listing, the counters and the health. */
437
489
  export async function memoryScopeReport(root, scope, caps, { onError } = {}) {
438
490
  const entries = await listMemory(root, scope, { onError });