@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.
- package/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- 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
|
+
}
|
package/src/core/index-html.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
352
|
-
'
|
|
353
|
-
'
|
|
354
|
-
'
|
|
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
|
|
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)
|
|
418
|
-
if (files >= T.files)
|
|
419
|
-
if (overBudget)
|
|
420
|
-
if (oversized.length)
|
|
421
|
-
if (overHard.length)
|
|
422
|
-
if (invalid.length)
|
|
423
|
-
if (alwaysOnBytes >= 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
|
|
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
|
-
:
|
|
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 });
|