@azure-id/orc 2.0.2 → 2.1.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/CHANGELOG.md +401 -0
- package/README-id.md +52 -23
- package/README.md +50 -22
- package/bin/build-agents.js +24 -25
- package/bin/clear-logs.js +620 -0
- package/bin/cli.js +1155 -158
- package/bin/fix.js +459 -0
- package/bin/gotcha.js +288 -8
- package/bin/habit.js +1538 -1453
- package/bin/mockrun-catalog.js +3 -0
- package/bin/onboarding-content.js +2 -1
- package/bin/pricing.json +28 -21
- package/bin/trace-write.js +869 -657
- package/bin/verify-contracts.js +108 -27
- package/bin/verify-package.js +17 -6
- package/bin/webui/api.js +125 -6
- package/bin/webui/css/panels/hookui.css +79 -0
- package/bin/webui/fixtures/behaviour.js +30 -0
- package/bin/webui/fixtures/extra.js +9 -9
- package/bin/webui/fixtures/flow.js +81 -81
- package/bin/webui/fixtures/hookui.js +506 -431
- package/bin/webui/fixtures/index.js +32 -3
- package/bin/webui/fixtures/knowledge.js +2 -2
- package/bin/webui/fixtures/maintenance.js +123 -42
- package/bin/webui/fixtures/rules.js +1007 -1002
- package/bin/webui/fixtures/settings.js +6 -7
- package/bin/webui/fixtures/stats.js +9 -6
- package/bin/webui/fixtures/wait.js +98 -97
- package/bin/webui/i18n/en/behaviour.json +13 -0
- package/bin/webui/i18n/en/hookui.json +290 -194
- package/bin/webui/i18n/en/maintenance.json +71 -52
- package/bin/webui/i18n/en/settings.json +60 -60
- package/bin/webui/i18n/id/behaviour.json +13 -0
- package/bin/webui/i18n/id/hookui.json +290 -194
- package/bin/webui/i18n/id/maintenance.json +71 -52
- package/bin/webui/i18n/id/settings.json +60 -60
- package/bin/webui/js/05-banners.js +181 -172
- package/bin/webui/js/06-edit.js +193 -183
- package/bin/webui/js/panels/behaviour.js +77 -12
- package/bin/webui/js/panels/hookui.js +2196 -1691
- package/bin/webui/js/panels/maintenance.js +336 -235
- package/bin/webui/js/panels/settings.js +726 -719
- package/mock-run/INDEX.md +1 -0
- package/mock-run/extra-slots.md +1 -1
- package/mock-run/habits.md +1 -1
- package/mock-run/orc-aftermath.md +392 -392
- package/mock-run/orc-budget.md +29 -33
- package/mock-run/orc-cli.md +200 -200
- package/mock-run/orc-explain.md +86 -86
- package/mock-run/orc-extra.md +389 -392
- package/mock-run/orc-fix.md +87 -0
- package/mock-run/orc-pattern.md +112 -112
- package/mock-run/orc.md +157 -157
- package/package.json +1 -1
- package/templates/agents/MODEL-MAPPING.md +32 -29
- package/templates/agents/orc-analyze-mini-sonnet-5-high.md +2 -2
- package/templates/agents/{orc-claude-writer-opus-5-med.md → orc-claude-writer-opus-5-low.md} +4 -4
- package/templates/agents/orc-executor-haiku-4-5.md +1 -1
- package/templates/agents/orc-executor-opus-5-low.md +1 -1
- package/templates/agents/orc-executor-sonnet-4-6-high.md +1 -1
- package/templates/agents/orc-executor-sonnet-4-6-med.md +1 -1
- package/templates/agents/orc-executor-sonnet-5-high.md +2 -2
- package/templates/agents/orc-executor-sonnet-5-low.md +155 -0
- package/templates/agents/orc-executor-sonnet-5-med.md +155 -0
- package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +2 -2
- package/templates/agents/orc-planner-mini-sonnet-5-high.md +2 -2
- package/templates/agents/orc-retro-sonnet-5-high.md +2 -2
- package/templates/agents/{orc-reviewer-opus-5-med.md → orc-reviewer-opus-5-low.md} +4 -4
- package/templates/agents/{orc-wiki-scanner-opus-5-med.md → orc-wiki-scanner-opus-5-low.md} +3 -3
- package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +2 -2
- package/templates/commands/orc-fix.md +18 -0
- package/templates/commands/orc-wiki.md +64 -64
- package/templates/hooks/README.md +75 -7
- package/templates/hooks/orc-session-hook.js +78 -3
- package/templates/hooks/orc-statusline-render.js +317 -23
- package/templates/hooks/orc-statusline.js +162 -49
- package/templates/hooks/orc-subagent-line.js +23 -5
- package/templates/hooks/orc-weather-fetch.js +123 -0
- package/templates/skills/_shared/extra-dispatch.md +5 -5
- package/templates/skills/_shared/gotchas.md +27 -7
- package/templates/skills/_shared/habits.md +2 -2
- package/templates/skills/_shared/opus5-only.md +14 -14
- package/templates/skills/_shared/phases/review.md +2 -2
- package/templates/skills/_shared/phases/trace-verbs.md +17 -3
- package/templates/skills/_shared/phases/trace.md +136 -136
- package/templates/skills/_shared/review-slice.md +28 -1
- package/templates/skills/_shared/wait.md +1 -0
- package/templates/skills/orc/README.md +2 -2
- package/templates/skills/orc/SKILL.md +1 -1
- package/templates/skills/orc/config.md +25 -27
- package/templates/skills/orc/examples/full-run-mock.md +2 -1
- package/templates/skills/orc/references/effort-and-mode.md +17 -17
- package/templates/skills/orc/references/preflight-report.md +3 -3
- package/templates/skills/orc/references/ultra-mode.md +1 -1
- package/templates/skills/orc/schemas/checkpoint.md +122 -122
- package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +3 -3
- package/templates/skills/orc/subskills/orc-review-verify/core.md +1 -1
- package/templates/skills/orc-analyze/SKILL.md +4 -2
- package/templates/skills/orc-claude/SKILL.md +14 -14
- package/templates/skills/orc-claude/examples/claude-run-mock.md +65 -65
- package/templates/skills/orc-diy/SKILL.md +5 -5
- package/templates/skills/orc-fast/SKILL.md +8 -6
- package/templates/skills/orc-fix/SKILL.md +110 -0
- package/templates/skills/orc-mini/SKILL.md +4 -4
- package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -2
- package/templates/skills/orc-quick/README.md +1 -1
- package/templates/skills/orc-quick/SKILL.md +2 -2
- package/templates/skills/orc-quick/references/dispatch-gate.md +3 -2
- package/templates/skills/orc-quick/references/gh-mode.md +4 -2
- package/templates/skills/orc-retro/SKILL.md +1 -1
- package/templates/skills/orc-retro/examples/retro-mock.md +16 -16
- package/templates/skills/orc-wiki/README.md +325 -325
- package/templates/skills/orc-wiki/SKILL.md +195 -195
- package/templates/skills/orc-wiki/examples/wiki-run-mock.md +37 -37
- package/templates/skills/orc-wiki/references/extra.md +2 -2
- package/templates/skills/orc-wiki/references/partial-refresh.md +276 -271
- package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -1
- package/templates/skills/orc-wiki/references/phases/phase-2.md +3 -3
- package/templates/skills/orc-wiki/references/phases/phase-3c.md +3 -2
- package/templates/agents/orc-claude-writer-opus-4-8-high.md +0 -48
- package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +0 -74
package/bin/trace-write.js
CHANGED
|
@@ -1,657 +1,869 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// v2.0.0 T21 — `orc trace write --packet -`: the CLI holds the trace pen.
|
|
3
|
-
//
|
|
4
|
-
// The orchestrator already builds a PHASE PACKET at every phase close
|
|
5
|
-
// (`_shared/phases/trace.md`). This module renders that ONE packet into BOTH
|
|
6
|
-
// halves of the trace pair — the `.txt` line and its `.jsonl` row — so the two
|
|
7
|
-
// can never disagree about an event. W0 measured the Haiku writer at 92.4 %
|
|
8
|
-
// (whole phase blocks missing from the `.jsonl`); here parity is by
|
|
9
|
-
// construction. The writer agent `orc-trace-writer-haiku-4-5` stays as the
|
|
10
|
-
// FALLBACK: a lane dispatches it only when this command exits ≠ 0.
|
|
11
|
-
//
|
|
12
|
-
// The formats are the writer's, not new ones (`agents/orc-trace-writer-haiku-4-5.md`):
|
|
13
|
-
// .txt [<ts>] <actor padded to 8> <VERB> :: <tail>
|
|
14
|
-
// .jsonl {"ts","actor","phase","verb","tail", …verbatim extra fields}
|
|
15
|
-
// NOTE one line from `decisions`, actor `writer`, the last event's ts
|
|
16
|
-
// and so are the file rules: the first (`run_meta`) packet renames a
|
|
17
|
-
// hook-bootstrapped `run-<DDMMYY>-<HHMMSS>.txt` (a MOVE of the .txt and its
|
|
18
|
-
// sidecars, reusing the bootstrap stamp); a later packet finds its file through
|
|
19
|
-
// `log_dir/.current`, so the FINISH packet must go out BEFORE `.current` is
|
|
20
|
-
// deleted. Nothing is written unless the WHOLE packet validates. Appends only —
|
|
21
|
-
// one append per file per block; an earlier line is never rewritten.
|
|
22
|
-
//
|
|
23
|
-
// Exit codes: 0 written · 1 usage / unreadable input · 2 invalid packet
|
|
24
|
-
// (unknown verb, bad ts, parse error — nothing written) · 3 trace-file state
|
|
25
|
-
// (no `.current` on a later packet, a rename target in the way).
|
|
26
|
-
|
|
27
|
-
const fs = require("fs");
|
|
28
|
-
const path = require("path");
|
|
29
|
-
|
|
30
|
-
const TS_RE = /^\d{6} \d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
|
31
|
-
const GENERIC_RE = /^run-(\d{6})-(\d{6})\.txt$/;
|
|
32
|
-
const CORE_FIELDS = ["ts", "actor", "verb", "tail"];
|
|
33
|
-
|
|
34
|
-
class PacketError extends Error {}
|
|
35
|
-
|
|
36
|
-
// ── the packet parser: JSON, or the small plain-YAML shape trace.md shows ──
|
|
37
|
-
|
|
38
|
-
function stripComment(s) {
|
|
39
|
-
// A ` #` outside quotes starts a comment (YAML plain-scalar rule).
|
|
40
|
-
let q = null;
|
|
41
|
-
for (let i = 0; i < s.length; i++) {
|
|
42
|
-
const c = s[i];
|
|
43
|
-
if (q) {
|
|
44
|
-
if (c === "\\" && q === '"') i++;
|
|
45
|
-
else if (c === q) q = null;
|
|
46
|
-
} else if (c === '"' || c === "'") {
|
|
47
|
-
if (i === 0 || /[\s:{,\[]/.test(s[i - 1])) q = c;
|
|
48
|
-
} else if (c === "#" && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i);
|
|
49
|
-
}
|
|
50
|
-
return s;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Read one quoted scalar starting at s[i] (a quote). → [value, nextIndex]
|
|
54
|
-
function readQuoted(s, i) {
|
|
55
|
-
const q = s[i];
|
|
56
|
-
let out = "";
|
|
57
|
-
for (let j = i + 1; j < s.length; j++) {
|
|
58
|
-
const c = s[j];
|
|
59
|
-
if (q === "'") {
|
|
60
|
-
if (c === "'") {
|
|
61
|
-
if (s[j + 1] === "'") {
|
|
62
|
-
out += "'";
|
|
63
|
-
j++;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
return [out, j + 1];
|
|
67
|
-
}
|
|
68
|
-
out += c;
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
if (c === "\\") {
|
|
72
|
-
const n = s[++j];
|
|
73
|
-
if (n === "n") out += "\n";
|
|
74
|
-
else if (n === "t") out += "\t";
|
|
75
|
-
else if (n === "r") out += "\r";
|
|
76
|
-
else if (n === "u") {
|
|
77
|
-
out += String.fromCharCode(parseInt(s.slice(j + 1, j + 5), 16));
|
|
78
|
-
j += 4;
|
|
79
|
-
} else out += n === undefined ? "" : n;
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (c === '"') return [out, j + 1];
|
|
83
|
-
out += c;
|
|
84
|
-
}
|
|
85
|
-
throw new PacketError(`unterminated ${q === '"' ? "double" : "single"}-quoted string`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function scalar(raw) {
|
|
89
|
-
const s = stripComment(String(raw)).trim();
|
|
90
|
-
if (s === "" || s === "~" || s === "null") return undefined;
|
|
91
|
-
if (s[0] === '"' || s[0] === "'") return readQuoted(s, 0)[0];
|
|
92
|
-
return s;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Parse a flow map `{k: v, k: "v, with comma"}` (one level; a nested value is
|
|
96
|
-
// kept as its raw text). → object
|
|
97
|
-
function flowMap(s) {
|
|
98
|
-
s = s.trim();
|
|
99
|
-
if (s[0] !== "{") throw new PacketError(`expected a {…} map, got: ${s.slice(0, 40)}`);
|
|
100
|
-
const out = {};
|
|
101
|
-
let i = 1;
|
|
102
|
-
const ws = () => {
|
|
103
|
-
while (i < s.length && /\s/.test(s[i])) i++;
|
|
104
|
-
};
|
|
105
|
-
for (;;) {
|
|
106
|
-
ws();
|
|
107
|
-
if (s[i] === "}") return out;
|
|
108
|
-
if (i >= s.length) throw new PacketError("unterminated {…} map");
|
|
109
|
-
let key;
|
|
110
|
-
if (s[i] === '"' || s[i] === "'") [key, i] = readQuoted(s, i);
|
|
111
|
-
else {
|
|
112
|
-
const k0 = i;
|
|
113
|
-
while (i < s.length && s[i] !== ":" && s[i] !== "," && s[i] !== "}") i++;
|
|
114
|
-
key = s.slice(k0, i).trim();
|
|
115
|
-
}
|
|
116
|
-
ws();
|
|
117
|
-
if (s[i] !== ":") throw new PacketError(`map key "${key}" has no value`);
|
|
118
|
-
i++;
|
|
119
|
-
ws();
|
|
120
|
-
let val;
|
|
121
|
-
if (s[i] === '"' || s[i] === "'") [val, i] = readQuoted(s, i);
|
|
122
|
-
else if (s[i] === "{" || s[i] === "[") {
|
|
123
|
-
const open = s[i], close = open === "{" ? "}" : "]";
|
|
124
|
-
let depth = 0;
|
|
125
|
-
const v0 = i;
|
|
126
|
-
for (; i < s.length; i++) {
|
|
127
|
-
if (s[i] === open) depth++;
|
|
128
|
-
else if (s[i] === close && --depth === 0) {
|
|
129
|
-
i++;
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
val = s.slice(v0, i);
|
|
134
|
-
} else {
|
|
135
|
-
const v0 = i;
|
|
136
|
-
while (i < s.length && s[i] !== "," && s[i] !== "}") i++;
|
|
137
|
-
val = s.slice(v0, i).trim();
|
|
138
|
-
if (val === "" || val === "~" || val === "null") val = undefined;
|
|
139
|
-
}
|
|
140
|
-
if (key) out[key] = val;
|
|
141
|
-
ws();
|
|
142
|
-
if (s[i] === ",") {
|
|
143
|
-
i++;
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
if (s[i] === "}") return out;
|
|
147
|
-
throw new PacketError(`expected "," or "}" after "${key}"`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const indentOf = (l) => l.length - l.replace(/^ +/, "").length;
|
|
152
|
-
const KEY_RE = /^([A-Za-z_][\w-]*)\s*:(?:\s+(.*)|\s*)$/;
|
|
153
|
-
|
|
154
|
-
// Collect a flow map that may span lines, starting at lines[i] (text from col).
|
|
155
|
-
function takeFlow(lines, i, first) {
|
|
156
|
-
let buf = first;
|
|
157
|
-
const bal = (t) => (t.match(/\{/g) || []).length - (t.match(/\}/g) || []).length;
|
|
158
|
-
while (bal(stripComment(buf)) > 0 && i + 1 < lines.length) buf += " " + lines[++i].trim();
|
|
159
|
-
return [flowMap(stripComment(buf)), i];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function blockScalar(lines, i, style, parentIndent) {
|
|
163
|
-
const body = [];
|
|
164
|
-
let j = i + 1;
|
|
165
|
-
for (; j < lines.length; j++) {
|
|
166
|
-
const l = lines[j];
|
|
167
|
-
if (l.trim() !== "" && indentOf(l) <= parentIndent) break;
|
|
168
|
-
body.push(l);
|
|
169
|
-
}
|
|
170
|
-
while (body.length && body[body.length - 1].trim() === "") body.pop();
|
|
171
|
-
const ind = Math.min(...body.filter((l) => l.trim()).map(indentOf).concat([Infinity]));
|
|
172
|
-
const rows = body.map((l) => (l.trim() === "" ? "" : l.slice(ind === Infinity ? 0 : ind)));
|
|
173
|
-
let text;
|
|
174
|
-
if (style[0] === "|") text = rows.join("\n");
|
|
175
|
-
else {
|
|
176
|
-
text = "";
|
|
177
|
-
for (const r of rows) text += r === "" ? "\n" : (text && !text.endsWith("\n") ? " " : "") + r;
|
|
178
|
-
}
|
|
179
|
-
return [text, j - 1];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function parseYaml(text) {
|
|
183
|
-
const lines = text.split("\n");
|
|
184
|
-
const out = {};
|
|
185
|
-
for (let i = 0; i < lines.length; i++) {
|
|
186
|
-
const line = lines[i];
|
|
187
|
-
if (line.trim() === "" || line.trim()[0] === "#") continue;
|
|
188
|
-
if (indentOf(line) !== 0) throw new PacketError(`line ${i + 1}: unexpected indentation`);
|
|
189
|
-
const m = KEY_RE.exec(line);
|
|
190
|
-
if (!m) throw new PacketError(`line ${i + 1}: expected "key: value"`);
|
|
191
|
-
const key = m[1];
|
|
192
|
-
const rest = stripComment(m[2] || "").trim();
|
|
193
|
-
if (/^[>|][+-]?$/.test(rest)) {
|
|
194
|
-
[out[key], i] = blockScalar(lines, i, rest, 0);
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (rest[0] === "{") {
|
|
198
|
-
[out[key], i] = takeFlow(lines, i, rest);
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (rest !== "") {
|
|
202
|
-
out[key] = scalar(m[2]);
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
// A nested block: a sequence (`- …`) or a one-level map.
|
|
206
|
-
let j = i + 1;
|
|
207
|
-
while (j < lines.length && (lines[j].trim() === "" || lines[j].trim()[0] === "#")) j++;
|
|
208
|
-
if (j >= lines.length || (indentOf(lines[j]) === 0 && !/^- /.test(lines[j]))) {
|
|
209
|
-
out[key] = undefined;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (/^\s*- /.test(lines[j]) || /^\s*-$/.test(lines[j])) {
|
|
213
|
-
const seq = [];
|
|
214
|
-
let k = j;
|
|
215
|
-
for (; k < lines.length; k++) {
|
|
216
|
-
const l = lines[k];
|
|
217
|
-
if (l.trim() === "" || l.trim()[0] === "#") continue;
|
|
218
|
-
if (indentOf(l) === 0 && !/^- /.test(l)) break;
|
|
219
|
-
const d = /^(\s*)- ?(.*)$/.exec(l);
|
|
220
|
-
if (!d) throw new PacketError(`line ${k + 1}: expected a "- " item`);
|
|
221
|
-
const itemIndent = d[1].length;
|
|
222
|
-
const body = d[2].trim();
|
|
223
|
-
if (body[0] === "{") {
|
|
224
|
-
let v;
|
|
225
|
-
[v, k] = takeFlow(lines, k, body);
|
|
226
|
-
seq.push(v);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
const km = KEY_RE.exec(body);
|
|
230
|
-
if (!km) {
|
|
231
|
-
seq.push(scalar(body));
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
// A block-map item: this line's pair plus every deeper-indented pair.
|
|
235
|
-
const item = {};
|
|
236
|
-
const put = (kk, vv, at) => {
|
|
237
|
-
const r = stripComment(vv || "").trim();
|
|
238
|
-
if (/^[>|][+-]?$/.test(r)) {
|
|
239
|
-
let t;
|
|
240
|
-
[t, k] = blockScalar(lines, at, r, itemIndent + 2);
|
|
241
|
-
item[kk] = t;
|
|
242
|
-
} else item[kk] = scalar(vv || "");
|
|
243
|
-
};
|
|
244
|
-
put(km[1], km[2], k);
|
|
245
|
-
while (k + 1 < lines.length) {
|
|
246
|
-
const n = lines[k + 1];
|
|
247
|
-
if (n.trim() === "") {
|
|
248
|
-
k++;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (indentOf(n) <= itemIndent) break;
|
|
252
|
-
const nm = KEY_RE.exec(n.trim());
|
|
253
|
-
if (!nm) throw new PacketError(`line ${k + 2}: expected "key: value" inside an item`);
|
|
254
|
-
k++;
|
|
255
|
-
put(nm[1], nm[2], k);
|
|
256
|
-
}
|
|
257
|
-
seq.push(item);
|
|
258
|
-
}
|
|
259
|
-
out[key] = seq;
|
|
260
|
-
i = k - 1;
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
const map = {};
|
|
264
|
-
let k = j;
|
|
265
|
-
for (; k < lines.length; k++) {
|
|
266
|
-
const l = lines[k];
|
|
267
|
-
if (l.trim() === "" || l.trim()[0] === "#") continue;
|
|
268
|
-
if (indentOf(l) === 0) break;
|
|
269
|
-
const nm = KEY_RE.exec(l.trim());
|
|
270
|
-
if (!nm) throw new PacketError(`line ${k + 1}: expected "key: value" under ${key}`);
|
|
271
|
-
map[nm[1]] = scalar(nm[2] || "");
|
|
272
|
-
}
|
|
273
|
-
out[key] = map;
|
|
274
|
-
i = k - 1;
|
|
275
|
-
}
|
|
276
|
-
return out;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function parsePacket(text) {
|
|
280
|
-
const t = String(text || "").replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
281
|
-
if (!t.trim()) throw new PacketError("the packet is empty");
|
|
282
|
-
if (t.trim()[0] === "{") {
|
|
283
|
-
try {
|
|
284
|
-
return JSON.parse(t);
|
|
285
|
-
} catch (e) {
|
|
286
|
-
throw new PacketError(`the packet is not valid JSON: ${e.message}`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return parseYaml(t);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ── validation + rendering (pure: the golden pair tests this half) ──
|
|
293
|
-
|
|
294
|
-
const oneLine = (v) =>
|
|
295
|
-
String(v)
|
|
296
|
-
.replace(/\r/g, "")
|
|
297
|
-
.split("\n")
|
|
298
|
-
.map((s) => s.trim())
|
|
299
|
-
.filter(Boolean)
|
|
300
|
-
.join(" · ");
|
|
301
|
-
|
|
302
|
-
function kebab(slug) {
|
|
303
|
-
return String(slug || "")
|
|
304
|
-
.toLowerCase()
|
|
305
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
306
|
-
.replace(/^-+/, "")
|
|
307
|
-
.slice(0, 32)
|
|
308
|
-
.replace(/-+$/, "");
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// The head shape of a verb, read from its grammar in TRACE_VERBS (the first form):
|
|
312
|
-
// `sep` = the grammar has a ` :: ` between head and tail; `args` = it names head
|
|
313
|
-
// arguments after the verb (a `[…]` part is optional and does not count).
|
|
314
|
-
const TAIL_ALIASES = ["note", "detail", "text"];
|
|
315
|
-
function headShape(grammar) {
|
|
316
|
-
const form = String(grammar || "").split(" · ")[0];
|
|
317
|
-
const sep = form.includes(" :: ");
|
|
318
|
-
const head = (sep ? form.slice(0, form.indexOf(" :: ")) : form).replace(/\[[^\]]*\]/g, " ");
|
|
319
|
-
const rest = head.replace(/<[^>]*>/g, "<x>").trim().split(/\s+/).slice(1).filter(Boolean);
|
|
320
|
-
return { sep, args: rest.length > 0 };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Put ONE event into the grammar's shape, or return an error. A lane may hand the
|
|
324
|
-
// detail over as `note`/`detail`/`text`, put ` :: ` inside `verb`, start the tail
|
|
325
|
-
// with `::`, or give a no-`::` verb only a tail — each is repaired, never guessed.
|
|
326
|
-
function shapeEvent(e, word, d) {
|
|
327
|
-
let verb = oneLine(e.verb == null ? "" : e.verb);
|
|
328
|
-
let tail = e.tail;
|
|
329
|
-
let alias = null;
|
|
330
|
-
if (tail == null || tail === "") for (const k of TAIL_ALIASES) if (e[k] != null && e[k] !== "") { tail = e[k]; alias = k; break; }
|
|
331
|
-
tail = tail == null ? "" : oneLine(tail);
|
|
332
|
-
const cut = verb.indexOf("::");
|
|
333
|
-
if (cut >= 0) { tail = [verb.slice(cut + 2).trim(), tail].filter(Boolean).join(" · "); verb = verb.slice(0, cut).trim(); }
|
|
334
|
-
tail = tail.replace(/^(?:::\s*)+/, "").trim();
|
|
335
|
-
const shape = d.grammar ? headShape(d.grammar) : null;
|
|
336
|
-
const bare = verb === word;
|
|
337
|
-
if (shape && !shape.sep && bare && tail) { verb = `${word} ${tail}`; tail = ""; }
|
|
338
|
-
else if (shape && shape.sep && shape.args && bare) {
|
|
339
|
-
const i = tail.indexOf(" :: ");
|
|
340
|
-
if (i > 0) { verb = `${word} ${tail.slice(0, i).trim()}`; tail = tail.slice(i + 4).trim(); }
|
|
341
|
-
else return { error: `${word} needs its head arguments before "::" — grammar: ${d.grammar.split(" · ")[0]} — put the whole head in \`verb\` (e.g. "${d.grammar.split(" :: ")[0].split(" · ")[0]}")` };
|
|
342
|
-
}
|
|
343
|
-
return { verb, tail: tail || undefined, alias };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
let H
|
|
579
|
-
try { H = require("./habit.js"); } catch (_) {}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
let
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
} catch (
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
// v2.0.0 T21 — `orc trace write --packet -`: the CLI holds the trace pen.
|
|
3
|
+
//
|
|
4
|
+
// The orchestrator already builds a PHASE PACKET at every phase close
|
|
5
|
+
// (`_shared/phases/trace.md`). This module renders that ONE packet into BOTH
|
|
6
|
+
// halves of the trace pair — the `.txt` line and its `.jsonl` row — so the two
|
|
7
|
+
// can never disagree about an event. W0 measured the Haiku writer at 92.4 %
|
|
8
|
+
// (whole phase blocks missing from the `.jsonl`); here parity is by
|
|
9
|
+
// construction. The writer agent `orc-trace-writer-haiku-4-5` stays as the
|
|
10
|
+
// FALLBACK: a lane dispatches it only when this command exits ≠ 0.
|
|
11
|
+
//
|
|
12
|
+
// The formats are the writer's, not new ones (`agents/orc-trace-writer-haiku-4-5.md`):
|
|
13
|
+
// .txt [<ts>] <actor padded to 8> <VERB> :: <tail>
|
|
14
|
+
// .jsonl {"ts","actor","phase","verb","tail", …verbatim extra fields}
|
|
15
|
+
// NOTE one line from `decisions`, actor `writer`, the last event's ts
|
|
16
|
+
// and so are the file rules: the first (`run_meta`) packet renames a
|
|
17
|
+
// hook-bootstrapped `run-<DDMMYY>-<HHMMSS>.txt` (a MOVE of the .txt and its
|
|
18
|
+
// sidecars, reusing the bootstrap stamp); a later packet finds its file through
|
|
19
|
+
// `log_dir/.current`, so the FINISH packet must go out BEFORE `.current` is
|
|
20
|
+
// deleted. Nothing is written unless the WHOLE packet validates. Appends only —
|
|
21
|
+
// one append per file per block; an earlier line is never rewritten.
|
|
22
|
+
//
|
|
23
|
+
// Exit codes: 0 written · 1 usage / unreadable input · 2 invalid packet
|
|
24
|
+
// (unknown verb, bad ts, parse error — nothing written) · 3 trace-file state
|
|
25
|
+
// (no `.current` on a later packet, a rename target in the way).
|
|
26
|
+
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const path = require("path");
|
|
29
|
+
|
|
30
|
+
const TS_RE = /^\d{6} \d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
|
31
|
+
const GENERIC_RE = /^run-(\d{6})-(\d{6})\.txt$/;
|
|
32
|
+
const CORE_FIELDS = ["ts", "actor", "verb", "tail"];
|
|
33
|
+
|
|
34
|
+
class PacketError extends Error {}
|
|
35
|
+
|
|
36
|
+
// ── the packet parser: JSON, or the small plain-YAML shape trace.md shows ──
|
|
37
|
+
|
|
38
|
+
function stripComment(s) {
|
|
39
|
+
// A ` #` outside quotes starts a comment (YAML plain-scalar rule).
|
|
40
|
+
let q = null;
|
|
41
|
+
for (let i = 0; i < s.length; i++) {
|
|
42
|
+
const c = s[i];
|
|
43
|
+
if (q) {
|
|
44
|
+
if (c === "\\" && q === '"') i++;
|
|
45
|
+
else if (c === q) q = null;
|
|
46
|
+
} else if (c === '"' || c === "'") {
|
|
47
|
+
if (i === 0 || /[\s:{,\[]/.test(s[i - 1])) q = c;
|
|
48
|
+
} else if (c === "#" && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i);
|
|
49
|
+
}
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Read one quoted scalar starting at s[i] (a quote). → [value, nextIndex]
|
|
54
|
+
function readQuoted(s, i) {
|
|
55
|
+
const q = s[i];
|
|
56
|
+
let out = "";
|
|
57
|
+
for (let j = i + 1; j < s.length; j++) {
|
|
58
|
+
const c = s[j];
|
|
59
|
+
if (q === "'") {
|
|
60
|
+
if (c === "'") {
|
|
61
|
+
if (s[j + 1] === "'") {
|
|
62
|
+
out += "'";
|
|
63
|
+
j++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return [out, j + 1];
|
|
67
|
+
}
|
|
68
|
+
out += c;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (c === "\\") {
|
|
72
|
+
const n = s[++j];
|
|
73
|
+
if (n === "n") out += "\n";
|
|
74
|
+
else if (n === "t") out += "\t";
|
|
75
|
+
else if (n === "r") out += "\r";
|
|
76
|
+
else if (n === "u") {
|
|
77
|
+
out += String.fromCharCode(parseInt(s.slice(j + 1, j + 5), 16));
|
|
78
|
+
j += 4;
|
|
79
|
+
} else out += n === undefined ? "" : n;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (c === '"') return [out, j + 1];
|
|
83
|
+
out += c;
|
|
84
|
+
}
|
|
85
|
+
throw new PacketError(`unterminated ${q === '"' ? "double" : "single"}-quoted string`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scalar(raw) {
|
|
89
|
+
const s = stripComment(String(raw)).trim();
|
|
90
|
+
if (s === "" || s === "~" || s === "null") return undefined;
|
|
91
|
+
if (s[0] === '"' || s[0] === "'") return readQuoted(s, 0)[0];
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse a flow map `{k: v, k: "v, with comma"}` (one level; a nested value is
|
|
96
|
+
// kept as its raw text). → object
|
|
97
|
+
function flowMap(s) {
|
|
98
|
+
s = s.trim();
|
|
99
|
+
if (s[0] !== "{") throw new PacketError(`expected a {…} map, got: ${s.slice(0, 40)}`);
|
|
100
|
+
const out = {};
|
|
101
|
+
let i = 1;
|
|
102
|
+
const ws = () => {
|
|
103
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
104
|
+
};
|
|
105
|
+
for (;;) {
|
|
106
|
+
ws();
|
|
107
|
+
if (s[i] === "}") return out;
|
|
108
|
+
if (i >= s.length) throw new PacketError("unterminated {…} map");
|
|
109
|
+
let key;
|
|
110
|
+
if (s[i] === '"' || s[i] === "'") [key, i] = readQuoted(s, i);
|
|
111
|
+
else {
|
|
112
|
+
const k0 = i;
|
|
113
|
+
while (i < s.length && s[i] !== ":" && s[i] !== "," && s[i] !== "}") i++;
|
|
114
|
+
key = s.slice(k0, i).trim();
|
|
115
|
+
}
|
|
116
|
+
ws();
|
|
117
|
+
if (s[i] !== ":") throw new PacketError(`map key "${key}" has no value`);
|
|
118
|
+
i++;
|
|
119
|
+
ws();
|
|
120
|
+
let val;
|
|
121
|
+
if (s[i] === '"' || s[i] === "'") [val, i] = readQuoted(s, i);
|
|
122
|
+
else if (s[i] === "{" || s[i] === "[") {
|
|
123
|
+
const open = s[i], close = open === "{" ? "}" : "]";
|
|
124
|
+
let depth = 0;
|
|
125
|
+
const v0 = i;
|
|
126
|
+
for (; i < s.length; i++) {
|
|
127
|
+
if (s[i] === open) depth++;
|
|
128
|
+
else if (s[i] === close && --depth === 0) {
|
|
129
|
+
i++;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
val = s.slice(v0, i);
|
|
134
|
+
} else {
|
|
135
|
+
const v0 = i;
|
|
136
|
+
while (i < s.length && s[i] !== "," && s[i] !== "}") i++;
|
|
137
|
+
val = s.slice(v0, i).trim();
|
|
138
|
+
if (val === "" || val === "~" || val === "null") val = undefined;
|
|
139
|
+
}
|
|
140
|
+
if (key) out[key] = val;
|
|
141
|
+
ws();
|
|
142
|
+
if (s[i] === ",") {
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (s[i] === "}") return out;
|
|
147
|
+
throw new PacketError(`expected "," or "}" after "${key}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const indentOf = (l) => l.length - l.replace(/^ +/, "").length;
|
|
152
|
+
const KEY_RE = /^([A-Za-z_][\w-]*)\s*:(?:\s+(.*)|\s*)$/;
|
|
153
|
+
|
|
154
|
+
// Collect a flow map that may span lines, starting at lines[i] (text from col).
|
|
155
|
+
function takeFlow(lines, i, first) {
|
|
156
|
+
let buf = first;
|
|
157
|
+
const bal = (t) => (t.match(/\{/g) || []).length - (t.match(/\}/g) || []).length;
|
|
158
|
+
while (bal(stripComment(buf)) > 0 && i + 1 < lines.length) buf += " " + lines[++i].trim();
|
|
159
|
+
return [flowMap(stripComment(buf)), i];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function blockScalar(lines, i, style, parentIndent) {
|
|
163
|
+
const body = [];
|
|
164
|
+
let j = i + 1;
|
|
165
|
+
for (; j < lines.length; j++) {
|
|
166
|
+
const l = lines[j];
|
|
167
|
+
if (l.trim() !== "" && indentOf(l) <= parentIndent) break;
|
|
168
|
+
body.push(l);
|
|
169
|
+
}
|
|
170
|
+
while (body.length && body[body.length - 1].trim() === "") body.pop();
|
|
171
|
+
const ind = Math.min(...body.filter((l) => l.trim()).map(indentOf).concat([Infinity]));
|
|
172
|
+
const rows = body.map((l) => (l.trim() === "" ? "" : l.slice(ind === Infinity ? 0 : ind)));
|
|
173
|
+
let text;
|
|
174
|
+
if (style[0] === "|") text = rows.join("\n");
|
|
175
|
+
else {
|
|
176
|
+
text = "";
|
|
177
|
+
for (const r of rows) text += r === "" ? "\n" : (text && !text.endsWith("\n") ? " " : "") + r;
|
|
178
|
+
}
|
|
179
|
+
return [text, j - 1];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseYaml(text) {
|
|
183
|
+
const lines = text.split("\n");
|
|
184
|
+
const out = {};
|
|
185
|
+
for (let i = 0; i < lines.length; i++) {
|
|
186
|
+
const line = lines[i];
|
|
187
|
+
if (line.trim() === "" || line.trim()[0] === "#") continue;
|
|
188
|
+
if (indentOf(line) !== 0) throw new PacketError(`line ${i + 1}: unexpected indentation`);
|
|
189
|
+
const m = KEY_RE.exec(line);
|
|
190
|
+
if (!m) throw new PacketError(`line ${i + 1}: expected "key: value"`);
|
|
191
|
+
const key = m[1];
|
|
192
|
+
const rest = stripComment(m[2] || "").trim();
|
|
193
|
+
if (/^[>|][+-]?$/.test(rest)) {
|
|
194
|
+
[out[key], i] = blockScalar(lines, i, rest, 0);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (rest[0] === "{") {
|
|
198
|
+
[out[key], i] = takeFlow(lines, i, rest);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (rest !== "") {
|
|
202
|
+
out[key] = scalar(m[2]);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// A nested block: a sequence (`- …`) or a one-level map.
|
|
206
|
+
let j = i + 1;
|
|
207
|
+
while (j < lines.length && (lines[j].trim() === "" || lines[j].trim()[0] === "#")) j++;
|
|
208
|
+
if (j >= lines.length || (indentOf(lines[j]) === 0 && !/^- /.test(lines[j]))) {
|
|
209
|
+
out[key] = undefined;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (/^\s*- /.test(lines[j]) || /^\s*-$/.test(lines[j])) {
|
|
213
|
+
const seq = [];
|
|
214
|
+
let k = j;
|
|
215
|
+
for (; k < lines.length; k++) {
|
|
216
|
+
const l = lines[k];
|
|
217
|
+
if (l.trim() === "" || l.trim()[0] === "#") continue;
|
|
218
|
+
if (indentOf(l) === 0 && !/^- /.test(l)) break;
|
|
219
|
+
const d = /^(\s*)- ?(.*)$/.exec(l);
|
|
220
|
+
if (!d) throw new PacketError(`line ${k + 1}: expected a "- " item`);
|
|
221
|
+
const itemIndent = d[1].length;
|
|
222
|
+
const body = d[2].trim();
|
|
223
|
+
if (body[0] === "{") {
|
|
224
|
+
let v;
|
|
225
|
+
[v, k] = takeFlow(lines, k, body);
|
|
226
|
+
seq.push(v);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const km = KEY_RE.exec(body);
|
|
230
|
+
if (!km) {
|
|
231
|
+
seq.push(scalar(body));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// A block-map item: this line's pair plus every deeper-indented pair.
|
|
235
|
+
const item = {};
|
|
236
|
+
const put = (kk, vv, at) => {
|
|
237
|
+
const r = stripComment(vv || "").trim();
|
|
238
|
+
if (/^[>|][+-]?$/.test(r)) {
|
|
239
|
+
let t;
|
|
240
|
+
[t, k] = blockScalar(lines, at, r, itemIndent + 2);
|
|
241
|
+
item[kk] = t;
|
|
242
|
+
} else item[kk] = scalar(vv || "");
|
|
243
|
+
};
|
|
244
|
+
put(km[1], km[2], k);
|
|
245
|
+
while (k + 1 < lines.length) {
|
|
246
|
+
const n = lines[k + 1];
|
|
247
|
+
if (n.trim() === "") {
|
|
248
|
+
k++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (indentOf(n) <= itemIndent) break;
|
|
252
|
+
const nm = KEY_RE.exec(n.trim());
|
|
253
|
+
if (!nm) throw new PacketError(`line ${k + 2}: expected "key: value" inside an item`);
|
|
254
|
+
k++;
|
|
255
|
+
put(nm[1], nm[2], k);
|
|
256
|
+
}
|
|
257
|
+
seq.push(item);
|
|
258
|
+
}
|
|
259
|
+
out[key] = seq;
|
|
260
|
+
i = k - 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const map = {};
|
|
264
|
+
let k = j;
|
|
265
|
+
for (; k < lines.length; k++) {
|
|
266
|
+
const l = lines[k];
|
|
267
|
+
if (l.trim() === "" || l.trim()[0] === "#") continue;
|
|
268
|
+
if (indentOf(l) === 0) break;
|
|
269
|
+
const nm = KEY_RE.exec(l.trim());
|
|
270
|
+
if (!nm) throw new PacketError(`line ${k + 1}: expected "key: value" under ${key}`);
|
|
271
|
+
map[nm[1]] = scalar(nm[2] || "");
|
|
272
|
+
}
|
|
273
|
+
out[key] = map;
|
|
274
|
+
i = k - 1;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parsePacket(text) {
|
|
280
|
+
const t = String(text || "").replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
281
|
+
if (!t.trim()) throw new PacketError("the packet is empty");
|
|
282
|
+
if (t.trim()[0] === "{") {
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(t);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
throw new PacketError(`the packet is not valid JSON: ${e.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return parseYaml(t);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── validation + rendering (pure: the golden pair tests this half) ──
|
|
293
|
+
|
|
294
|
+
const oneLine = (v) =>
|
|
295
|
+
String(v)
|
|
296
|
+
.replace(/\r/g, "")
|
|
297
|
+
.split("\n")
|
|
298
|
+
.map((s) => s.trim())
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.join(" · ");
|
|
301
|
+
|
|
302
|
+
function kebab(slug) {
|
|
303
|
+
return String(slug || "")
|
|
304
|
+
.toLowerCase()
|
|
305
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
306
|
+
.replace(/^-+/, "")
|
|
307
|
+
.slice(0, 32)
|
|
308
|
+
.replace(/-+$/, "");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The head shape of a verb, read from its grammar in TRACE_VERBS (the first form):
|
|
312
|
+
// `sep` = the grammar has a ` :: ` between head and tail; `args` = it names head
|
|
313
|
+
// arguments after the verb (a `[…]` part is optional and does not count).
|
|
314
|
+
const TAIL_ALIASES = ["note", "detail", "text"];
|
|
315
|
+
function headShape(grammar) {
|
|
316
|
+
const form = String(grammar || "").split(" · ")[0];
|
|
317
|
+
const sep = form.includes(" :: ");
|
|
318
|
+
const head = (sep ? form.slice(0, form.indexOf(" :: ")) : form).replace(/\[[^\]]*\]/g, " ");
|
|
319
|
+
const rest = head.replace(/<[^>]*>/g, "<x>").trim().split(/\s+/).slice(1).filter(Boolean);
|
|
320
|
+
return { sep, args: rest.length > 0 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Put ONE event into the grammar's shape, or return an error. A lane may hand the
|
|
324
|
+
// detail over as `note`/`detail`/`text`, put ` :: ` inside `verb`, start the tail
|
|
325
|
+
// with `::`, or give a no-`::` verb only a tail — each is repaired, never guessed.
|
|
326
|
+
function shapeEvent(e, word, d) {
|
|
327
|
+
let verb = oneLine(e.verb == null ? "" : e.verb);
|
|
328
|
+
let tail = e.tail;
|
|
329
|
+
let alias = null;
|
|
330
|
+
if (tail == null || tail === "") for (const k of TAIL_ALIASES) if (e[k] != null && e[k] !== "") { tail = e[k]; alias = k; break; }
|
|
331
|
+
tail = tail == null ? "" : oneLine(tail);
|
|
332
|
+
const cut = verb.indexOf("::");
|
|
333
|
+
if (cut >= 0) { tail = [verb.slice(cut + 2).trim(), tail].filter(Boolean).join(" · "); verb = verb.slice(0, cut).trim(); }
|
|
334
|
+
tail = tail.replace(/^(?:::\s*)+/, "").trim();
|
|
335
|
+
const shape = d.grammar ? headShape(d.grammar) : null;
|
|
336
|
+
const bare = verb === word;
|
|
337
|
+
if (shape && !shape.sep && bare && tail) { verb = `${word} ${tail}`; tail = ""; }
|
|
338
|
+
else if (shape && shape.sep && shape.args && bare) {
|
|
339
|
+
const i = tail.indexOf(" :: ");
|
|
340
|
+
if (i > 0) { verb = `${word} ${tail.slice(0, i).trim()}`; tail = tail.slice(i + 4).trim(); }
|
|
341
|
+
else return { error: `${word} needs its head arguments before "::" — grammar: ${d.grammar.split(" · ")[0]} — put the whole head in \`verb\` (e.g. "${d.grammar.split(" :: ")[0].split(" · ")[0]}")` };
|
|
342
|
+
}
|
|
343
|
+
return { verb, tail: tail || undefined, alias };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// v2.1.0 — the review-close line has ONE grammar, and `orc gotcha quality`
|
|
347
|
+
// now reads it. A per-finding line (`FINDING-OUTCOME F1 :: sev=P3
|
|
348
|
+
// outcome=deferred`) or an outcome outside the closed set is refused BY NAME,
|
|
349
|
+
// so a lane can never write a line the reader silently skips.
|
|
350
|
+
const FINDING_OUTCOMES = ["addressed", "disputed", "wontfix", "open", "pre", "suppressed"];
|
|
351
|
+
const FINDING_HEAD = /^FINDING-OUTCOME addressed=\d+ disputed=\d+ wontfix=\d+ open=\d+ pre=\d+ suppressed=\d+$/;
|
|
352
|
+
const FINDING_TAIL = /^(?:clean|[a-z][a-z.-]*:\d+\/\d+(?:\s*,\s*[a-z][a-z.-]*:\d+\/\d+)*)$/;
|
|
353
|
+
const FINDING_COUNT_HEAD = /^FINDING p0=\d+ p1=\d+ p2=\d+ p3=\d+(?: pre=\d+)?(?: suppressed=\d+)?(?: folded=\d+)?$/;
|
|
354
|
+
const REVIEW_WHICH_HEAD = /^REVIEW-WHICH chose=(orc|project|skip) name=(\S+) by=(user|ledger|learned)$/;
|
|
355
|
+
function reviewWhichError(verb, tail) {
|
|
356
|
+
const m = REVIEW_WHICH_HEAD.exec(verb);
|
|
357
|
+
if (!m)
|
|
358
|
+
return 'REVIEW-WHICH: the head must be "REVIEW-WHICH chose=<orc|project|skip> name=<the review the rule names> by=<user|ledger|learned>"';
|
|
359
|
+
if (m[2] === "none") return "REVIEW-WHICH: name= is the review the project rule names (e.g. /code-review), never none";
|
|
360
|
+
if (!tail || !/^\S+:\d+$/.test(tail)) return 'REVIEW-WHICH: the tail is the rule\'s "<file>:<line>" from `orc review policy` (e.g. "CLAUDE.md:42")';
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
function findingOutcomeError(verb, tail) {
|
|
364
|
+
const all = `${verb} ${tail || ""}`;
|
|
365
|
+
const unknown = [...all.matchAll(/\boutcome=([a-z_-]+)/g)].map((m) => m[1]).filter((o) => !FINDING_OUTCOMES.includes(o));
|
|
366
|
+
if (unknown.length)
|
|
367
|
+
return `FINDING-OUTCOME: unknown outcome "${unknown[0]}" — the closed set is ${FINDING_OUTCOMES.slice(0, 4).join(" · ")}; write ONE line for the whole review, not one per finding`;
|
|
368
|
+
if (!FINDING_HEAD.test(verb))
|
|
369
|
+
return 'FINDING-OUTCOME: the head must be exactly "FINDING-OUTCOME addressed=<n> disputed=<n> wontfix=<n> open=<n> pre=<n> suppressed=<n>" — ONE line per review, never one per finding';
|
|
370
|
+
if (tail && !FINDING_TAIL.test(tail))
|
|
371
|
+
return 'FINDING-OUTCOME: the tail must be "<category>:<addressed>/<total>,…" or "clean"';
|
|
372
|
+
// v2.1.0 (E21 D5) — a live /orc run wrote `logic:0/4,test-teardown:0/1`. The
|
|
373
|
+
// categories are the review return's CLOSED set (the observations use it).
|
|
374
|
+
if (tail && tail !== "clean") {
|
|
375
|
+
let cats = [];
|
|
376
|
+
try { cats = require("./gotcha.js").CATEGORIES || []; } catch (_) {}
|
|
377
|
+
const bad = cats.length ? tail.split(",").map((x) => x.trim().split(":")[0]).filter((c) => c !== "uncategorized" && !cats.includes(c)) : [];
|
|
378
|
+
if (bad.length) return `FINDING-OUTCOME: unknown category "${bad[0]}" — use the review return's own: ${cats.join(" · ")} (or uncategorized); a review with no finding is the tail "clean"`;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// → { phase, run_meta, events: [{ts, actor, verb, tail, extra}], note } or throws.
|
|
384
|
+
function validatePacket(pk, traceVerbs) {
|
|
385
|
+
if (!pk || typeof pk !== "object" || Array.isArray(pk)) throw new PacketError("the packet is not a map");
|
|
386
|
+
const errs = [];
|
|
387
|
+
const events = Array.isArray(pk.events) ? pk.events : [];
|
|
388
|
+
if (pk.events !== undefined && !Array.isArray(pk.events)) errs.push("events must be a list");
|
|
389
|
+
if (!events.length) errs.push("the packet has no events — zero new trace lines is a protocol violation");
|
|
390
|
+
const out = [];
|
|
391
|
+
events.forEach((e, n) => {
|
|
392
|
+
const at = `events[${n}]`;
|
|
393
|
+
if (!e || typeof e !== "object") return errs.push(`${at} is not a {ts, verb, tail} map`);
|
|
394
|
+
const verb = oneLine(e.verb == null ? "" : e.verb);
|
|
395
|
+
const word = verb.split(/\s+/)[0];
|
|
396
|
+
if (!word) return errs.push(`${at} has no verb`);
|
|
397
|
+
const d = Object.prototype.hasOwnProperty.call(traceVerbs, word) ? traceVerbs[word] : null;
|
|
398
|
+
if (!d) return errs.push(`${at}: unknown verb ${word} — not in the CLOSED set (trace-verbs.md)`);
|
|
399
|
+
if (d.emitter === "hook") return errs.push(`${at}: ${word} is written by the hook, never by a packet`);
|
|
400
|
+
// v2.1.0 W6 — `FIX` is written by `orc fix record` itself.
|
|
401
|
+
if (d.emitter === "cli") return errs.push(`${at}: ${word} is written by the CLI (\`orc fix record\`), never by a packet`);
|
|
402
|
+
const ts = e.ts == null ? "" : String(e.ts).trim();
|
|
403
|
+
if (!TS_RE.test(ts)) errs.push(`${at} (${word}): ts "${ts}" is not DDMMYY HH:MM:SS.mmm — the event's REAL time, never "now"`);
|
|
404
|
+
const actor = e.actor == null || e.actor === "" ? "orc" : String(e.actor).trim();
|
|
405
|
+
if (/\s/.test(actor)) errs.push(`${at} (${word}): actor "${actor}" has a space`);
|
|
406
|
+
const shaped = shapeEvent(e, word, d);
|
|
407
|
+
if (shaped.error) return errs.push(`${at}: ${shaped.error}`);
|
|
408
|
+
if (word === "FINDING-OUTCOME") {
|
|
409
|
+
const bad = findingOutcomeError(shaped.verb, shaped.tail);
|
|
410
|
+
if (bad) return errs.push(`${at}: ${bad}`);
|
|
411
|
+
}
|
|
412
|
+
// v2.1.0 (E21 D2, D3) — a live run wrote `FINDING 9 findings — P0×2 …` and
|
|
413
|
+
// `REVIEW-WHICH chose=orc name=none … :: CLAUDE.md:Review policy`.
|
|
414
|
+
if (word === "FINDING" && !FINDING_COUNT_HEAD.test(shaped.verb))
|
|
415
|
+
return errs.push(`${at}: FINDING: the head must be "FINDING p0=<n> p1=<n> p2=<n> p3=<n>" (optionally " pre=<n> suppressed=<n> folded=<n>") — counts only, the detail goes in the tail`);
|
|
416
|
+
if (word === "REVIEW-WHICH") {
|
|
417
|
+
const bad = reviewWhichError(shaped.verb, shaped.tail);
|
|
418
|
+
if (bad) return errs.push(`${at}: ${bad}`);
|
|
419
|
+
}
|
|
420
|
+
const extra = {};
|
|
421
|
+
for (const [k, v] of Object.entries(e)) {
|
|
422
|
+
if (CORE_FIELDS.includes(k) || k === "phase" || k === shaped.alias || v === undefined) continue;
|
|
423
|
+
extra[k] = typeof v === "string" && /^-?\d+(?:\.\d+)?$/.test(v) ? Number(v) : v;
|
|
424
|
+
}
|
|
425
|
+
out.push({ ts, actor, verb: shaped.verb, tail: shaped.tail, extra });
|
|
426
|
+
});
|
|
427
|
+
const rm = pk.run_meta;
|
|
428
|
+
if (rm !== undefined && rm !== null) {
|
|
429
|
+
if (typeof rm !== "object") errs.push("run_meta must be a map");
|
|
430
|
+
else {
|
|
431
|
+
if (!rm.lane || !/^[a-z0-9]+$/.test(String(rm.lane))) errs.push(`run_meta.lane "${rm.lane || ""}" is not a lane token`);
|
|
432
|
+
if (!kebab(rm.slug)) errs.push("run_meta.slug is missing");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (errs.length) {
|
|
436
|
+
const err = new PacketError(errs.join("; "));
|
|
437
|
+
err.errors = errs;
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
const decisions = pk.decisions == null ? "" : oneLine(pk.decisions);
|
|
441
|
+
return {
|
|
442
|
+
phase: pk.phase == null ? undefined : oneLine(pk.phase),
|
|
443
|
+
run: typeof pk.run === "string" && /^run-[a-z0-9]+-[a-z0-9-]+-\d{6}-\d{6}(\.txt)?$/.test(pk.run) ? pk.run : null,
|
|
444
|
+
run_meta: rm || null,
|
|
445
|
+
events: out,
|
|
446
|
+
note: decisions ? { ts: out[out.length - 1].ts, actor: "writer", verb: "NOTE", tail: decisions, extra: {} } : null,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// → { txt: [lines], jsonl: [lines] } — SAME order and SAME count.
|
|
451
|
+
function renderBlock(v) {
|
|
452
|
+
const all = v.events.concat(v.note ? [v.note] : []);
|
|
453
|
+
const txt = [];
|
|
454
|
+
const jsonl = [];
|
|
455
|
+
for (const e of all) {
|
|
456
|
+
txt.push(`[${e.ts}] ${e.actor.padEnd(8)} ${e.verb}` + (e.tail ? ` :: ${e.tail}` : ""));
|
|
457
|
+
const row = { ts: e.ts, actor: e.actor };
|
|
458
|
+
if (v.phase !== undefined) row.phase = v.phase;
|
|
459
|
+
row.verb = e.verb;
|
|
460
|
+
if (e.tail !== undefined) row.tail = e.tail;
|
|
461
|
+
jsonl.push(JSON.stringify(Object.assign(row, e.extra)));
|
|
462
|
+
}
|
|
463
|
+
return { txt, jsonl };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── the file half ──
|
|
467
|
+
|
|
468
|
+
const countLines = (f) => {
|
|
469
|
+
try {
|
|
470
|
+
const t = fs.readFileSync(f, "utf8");
|
|
471
|
+
return (t.match(/\n/g) || []).length + (t && !t.endsWith("\n") ? 1 : 0);
|
|
472
|
+
} catch (_) {
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const exists = (f) => {
|
|
477
|
+
try {
|
|
478
|
+
fs.statSync(f);
|
|
479
|
+
return true;
|
|
480
|
+
} catch (_) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// One append for the whole block. A file whose last line has no newline gets
|
|
486
|
+
// one first, so a block never glues onto an earlier line (never rewrites it).
|
|
487
|
+
function appendBlock(file, rows) {
|
|
488
|
+
if (!rows.length) return;
|
|
489
|
+
let lead = "";
|
|
490
|
+
try {
|
|
491
|
+
const st = fs.statSync(file);
|
|
492
|
+
if (st.size > 0) {
|
|
493
|
+
const fd = fs.openSync(file, "r");
|
|
494
|
+
const b = Buffer.alloc(1);
|
|
495
|
+
fs.readSync(fd, b, 0, 1, st.size - 1);
|
|
496
|
+
fs.closeSync(fd);
|
|
497
|
+
if (b[0] !== 0x0a) lead = "\n";
|
|
498
|
+
}
|
|
499
|
+
} catch (_) {}
|
|
500
|
+
fs.appendFileSync(file, lead + rows.join("\n") + "\n");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
class StateError extends Error {}
|
|
504
|
+
|
|
505
|
+
// Decide the file against DISK (the writer's rename rule), then append.
|
|
506
|
+
function writePacket(claudeDir, logDir, v, block) {
|
|
507
|
+
const root = path.dirname(claudeDir);
|
|
508
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
509
|
+
const ptrFile = path.join(logDir, ".current");
|
|
510
|
+
let ptr = null;
|
|
511
|
+
try {
|
|
512
|
+
ptr = fs.readFileSync(ptrFile, "utf8").trim() || null;
|
|
513
|
+
} catch (_) {}
|
|
514
|
+
let target;
|
|
515
|
+
let renamed = false;
|
|
516
|
+
let repointed = false;
|
|
517
|
+
if (v.run_meta) {
|
|
518
|
+
const rm = v.run_meta;
|
|
519
|
+
const given = rm.trace_path ? (path.isAbsolute(rm.trace_path) ? rm.trace_path : path.join(root, rm.trace_path)) : null;
|
|
520
|
+
const g = ptr && GENERIC_RE.exec(ptr);
|
|
521
|
+
if (g && exists(path.join(logDir, ptr)) && (!given || path.basename(given) !== ptr)) {
|
|
522
|
+
// The clobber signature: a rich packet name beside a generic bootstrap
|
|
523
|
+
// pointer. MOVE the bootstrap file and its sidecars; reuse its stamp.
|
|
524
|
+
const name = `run-${rm.lane}-${kebab(rm.slug)}-${g[1]}-${g[2]}.txt`;
|
|
525
|
+
const dest = path.join(logDir, name);
|
|
526
|
+
if (exists(dest)) {
|
|
527
|
+
if (fs.statSync(dest).size > 0) throw new StateError(`rename target ${name} already exists and is not empty`);
|
|
528
|
+
fs.unlinkSync(dest);
|
|
529
|
+
}
|
|
530
|
+
for (const sfx of ["", ".pending.json", ".jsonl"]) {
|
|
531
|
+
const from = path.join(logDir, ptr + sfx);
|
|
532
|
+
if (exists(from)) fs.renameSync(from, path.join(logDir, name + sfx));
|
|
533
|
+
}
|
|
534
|
+
fs.writeFileSync(ptrFile, name + "\n");
|
|
535
|
+
target = dest;
|
|
536
|
+
renamed = true;
|
|
537
|
+
} else if (given) {
|
|
538
|
+
target = given;
|
|
539
|
+
} else if (ptr) {
|
|
540
|
+
target = path.join(logDir, ptr);
|
|
541
|
+
} else throw new StateError("run_meta has no trace_path and log_dir/.current is missing — nothing names the trace file");
|
|
542
|
+
// A pointer naming some OTHER existing file is repointed after the write
|
|
543
|
+
// (the file then exists); a missing pointer is never invented here.
|
|
544
|
+
if (ptr && !renamed && path.basename(target) !== ptr && path.dirname(path.resolve(target)) === path.resolve(logDir)) repointed = true;
|
|
545
|
+
} else {
|
|
546
|
+
// A late packet (the ASK answers after the habits nudge) may arrive after the
|
|
547
|
+
// lane removed `.current`: its `run:` names an EXISTING trace in log_dir.
|
|
548
|
+
const late = !ptr && v.run ? path.join(logDir, path.basename(v.run).replace(/(\.txt)?$/, ".txt")) : null;
|
|
549
|
+
if (late && exists(late)) target = late;
|
|
550
|
+
else if (!ptr)
|
|
551
|
+
throw new StateError("log_dir/.current is missing and this packet has no run_meta — the FINISH packet must be written BEFORE .current is deleted");
|
|
552
|
+
else target = path.join(logDir, ptr);
|
|
553
|
+
}
|
|
554
|
+
const jsonl = target + ".jsonl";
|
|
555
|
+
const before = [countLines(target), countLines(jsonl)];
|
|
556
|
+
appendBlock(target, block.txt);
|
|
557
|
+
appendBlock(jsonl, block.jsonl);
|
|
558
|
+
if (repointed) fs.writeFileSync(ptrFile, path.basename(target) + "\n");
|
|
559
|
+
return {
|
|
560
|
+
trace_path: path.relative(root, target).split(path.sep).join("/"),
|
|
561
|
+
lines_written: countLines(target) - before[0],
|
|
562
|
+
jsonl_written: countLines(jsonl) - before[1],
|
|
563
|
+
renamed,
|
|
564
|
+
repointed,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const USAGE = "usage: orc trace write --packet -|<file> [--json] (reads one phase packet; writes the .txt + .jsonl pair)";
|
|
569
|
+
|
|
570
|
+
// deps: { flag, positionals, emitJson, wantsJson, resolveClaudeDir, resolveLogDir, TRACE_VERBS, readOverride, reviewPolicyCompute, logSweep }
|
|
571
|
+
// The habits capture check (eval E2, 27-09-2026): a live lane read habits.md at
|
|
572
|
+
// preflight and then wrote no ASK at the question it asked minutes later. So at the
|
|
573
|
+
// FINISH packet, with `habits` on and ZERO `ASK` lines in the run, the CLI names
|
|
574
|
+
// this lane's registered questions and asks for ONE more packet with the answers.
|
|
575
|
+
// It never blocks: the packet is already written, the exit stays 0.
|
|
576
|
+
function askNudge(deps, claudeDir, v, r) {
|
|
577
|
+
if (!deps.readOverride || !v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) return null;
|
|
578
|
+
let H;
|
|
579
|
+
try { H = require("./habit.js"); } catch (_) { return null; }
|
|
580
|
+
let mode = "off";
|
|
581
|
+
try { mode = H.habitsMode(deps.readOverride(claudeDir).map); } catch (_) {}
|
|
582
|
+
if (mode === "off") return null;
|
|
583
|
+
const file = path.join(path.dirname(claudeDir), r.trace_path);
|
|
584
|
+
let txt = "";
|
|
585
|
+
try { txt = fs.readFileSync(file, "utf8"); } catch (_) { return null; }
|
|
586
|
+
// The qids this run already recorded (a VALID ASK line — invalid ones never reach the file).
|
|
587
|
+
const done = new Set();
|
|
588
|
+
for (const m of txt.matchAll(/\] \S+\s+ASK (\S+) ::/g)) done.add(m[1]);
|
|
589
|
+
const tok = (/(?:^|\/)run-([a-z0-9]+)-/.exec(r.trace_path) || [])[1];
|
|
590
|
+
if (!tok) return null;
|
|
591
|
+
const lane = tok === "orc" || tok === "ultra" ? "orc" : `orc-${tok}`;
|
|
592
|
+
const qids = Object.keys(H.ASK_POINTS).filter((q) => (H.ASK_POINTS[q].lanes || []).includes(lane) && !done.has(q));
|
|
593
|
+
if (!qids.length) return null;
|
|
594
|
+
return {
|
|
595
|
+
qids,
|
|
596
|
+
line: `habits: ${mode} — ${done.size ? `this run recorded ASK for ${[...done].join(", ")} only` : "this run wrote NO ASK line"}. If you asked the user any question below, write ONE more packet now (with \`run: ${path.basename(r.trace_path)}\` — it works even after .current is removed): one ASK event per question you asked the user that is marked (H <qid>) — ${lane}: ${qids.map((q) => { const o = Object.keys(H.ASK_POINTS[q].options || {}); return o.length ? `${q} (${o.join("|")})` : `${q} (the agent names you offered)`; }).join(", ")}. Use THESE option ids exactly. verb "ASK <qid>", tail "offered=<o1|o2|…> rec=<o|none> chose=<o|other> by=<user|ledger|learned|config|default>". A question you did not ask this run → no event.`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// v2.1.0 (E21 D6) — the phase-skip nudge. A live /orc run built the plan inline
|
|
601
|
+
// and skipped review AND verify "as a proportionality call" until the user asked.
|
|
602
|
+
// /orc ALWAYS reviews and verifies (`orc/SKILL.md`, the phase manifest). At FINISH,
|
|
603
|
+
// an /orc or /orc-ultra trace with an executor SPAWN and no reviewer or no verifier
|
|
604
|
+
// SPAWN gets ONE line naming what is missing. Never a block: the exit stays 0.
|
|
605
|
+
function phaseNudge(v, r, claudeDir) {
|
|
606
|
+
if (!v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) return null;
|
|
607
|
+
const tok = (/(?:^|\/)run-([a-z0-9]+)-/.exec(r.trace_path) || [])[1];
|
|
608
|
+
if (tok !== "orc" && tok !== "ultra") return null;
|
|
609
|
+
let txt = "";
|
|
610
|
+
try { txt = fs.readFileSync(path.join(path.dirname(claudeDir), r.trace_path), "utf8"); } catch (_) { return null; }
|
|
611
|
+
if (!/\]\s+hook\s+SPAWN\s+orc-executor-/.test(txt)) return null;
|
|
612
|
+
const missing = [];
|
|
613
|
+
if (!/\]\s+hook\s+SPAWN\s+orc-reviewer-/.test(txt)) missing.push("review (Phase 5, orc-reviewer-opus-5-low)");
|
|
614
|
+
if (!/\]\s+hook\s+SPAWN\s+orc-verifier-/.test(txt)) missing.push("verify (Phase 6, orc-verifier-opus-5-med)");
|
|
615
|
+
if (!missing.length) return null;
|
|
616
|
+
return {
|
|
617
|
+
missing,
|
|
618
|
+
line: `phases: this /orc run changed code and has no ${missing.join(" and no ")}. /orc always runs both — they are not optional for a small change. Run them now, before the summary; a run that should skip them belongs in /orc-mini or /orc-fast.`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// v2.1.0 — the observe nudge (the askNudge model). At the FINISH packet, a run
|
|
623
|
+
// whose trace has a reviewer RETURN, whose review was NOT clean, and that has no
|
|
624
|
+
// `author: orc` observation with `run=<this trace>` gets ONE line naming
|
|
625
|
+
// `orc gotcha observe`. Review Quality counts the review from the trace either
|
|
626
|
+
// way; this is the acceptance half. It never blocks: the exit stays 0.
|
|
627
|
+
function observeNudge(claudeDir, v, r) {
|
|
628
|
+
if (!v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) return null;
|
|
629
|
+
const file = path.join(path.dirname(claudeDir), r.trace_path);
|
|
630
|
+
let txt = "";
|
|
631
|
+
try { txt = fs.readFileSync(file, "utf8"); } catch (_) { return null; }
|
|
632
|
+
if (!/\]\s+hook\s+RETURN\s+orc-reviewer-/.test(txt)) return null;
|
|
633
|
+
const fo = /FINDING-OUTCOME addressed=(\d+) disputed=(\d+) wontfix=(\d+) open=(\d+)/.exec(txt);
|
|
634
|
+
if (fo && fo.slice(1, 5).every((n) => n === "0")) return null; // a clean review records nothing
|
|
635
|
+
const run = path.basename(r.trace_path).replace(/\.txt$/, "");
|
|
636
|
+
let obs = "";
|
|
637
|
+
try { obs = fs.readFileSync(path.join(claudeDir, "orc", "observations.jsonl"), "utf8"); } catch (_) {}
|
|
638
|
+
for (const line of obs.split(/\r?\n/)) {
|
|
639
|
+
if (!line.trim()) continue;
|
|
640
|
+
try {
|
|
641
|
+
const o = JSON.parse(line);
|
|
642
|
+
if (o && o.author === "orc" && o.run === run) return null;
|
|
643
|
+
} catch (_) {}
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
run,
|
|
647
|
+
line: `review: this run has a reviewer RETURN and no recorded finding outcome. Pipe ONE object per finding to \`orc gotcha observe - --json\` (source review · author orc · run ${run} · ref "${run} · F<n>" · outcome addressed|disputed|wontfix|open · rule or sig), then write the FINDING-OUTCOME line if it is missing. A clean review needs neither. Every lane that ran a review records this, /orc-fast too: an observation is review learning, not a repair-memory entry.`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// v2.1.0 — the which-review nudge. At FINISH: the project names its own review
|
|
652
|
+
// (`orc review policy`, re-run here — the files are small), the run changed code
|
|
653
|
+
// (a hook SPAWN of an executor, or a quick code entry), and the trace has no
|
|
654
|
+
// REVIEW-WHICH line → ONE line. Never a block: the exit stays 0.
|
|
655
|
+
function reviewWhichNudge(deps, claudeDir, v, r) {
|
|
656
|
+
if (typeof deps.reviewPolicyCompute !== "function") return null;
|
|
657
|
+
if (!v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) return null;
|
|
658
|
+
const file = path.join(path.dirname(claudeDir), r.trace_path);
|
|
659
|
+
let txt = "";
|
|
660
|
+
try { txt = fs.readFileSync(file, "utf8"); } catch (_) { return null; }
|
|
661
|
+
if (/\]\s+\S+\s+REVIEW-WHICH\b/.test(txt)) return null;
|
|
662
|
+
const changed = /\]\s+hook\s+SPAWN\s+orc-executor-/.test(txt) || /\]\s+\S+\s+GRAPH-UPDATE\b/.test(txt);
|
|
663
|
+
if (!changed) return null;
|
|
664
|
+
let p;
|
|
665
|
+
try { p = deps.reviewPolicyCompute(claudeDir); } catch (_) { return null; }
|
|
666
|
+
if (!p || p.policy !== "project" || !p.found.length) return null;
|
|
667
|
+
const f = p.found[0];
|
|
668
|
+
return {
|
|
669
|
+
rule: `${f.file}:${f.line}`,
|
|
670
|
+
name: f.names[0],
|
|
671
|
+
line: `review: ${f.file}:${f.line} names ${f.names[0]} and this run changed code with no REVIEW-WHICH line. Before ship, ask "which review" (_shared/review-slice.md §0), then write ONE packet: verb "REVIEW-WHICH chose=<orc|project|skip> name=<name|none> by=user", tail "${f.file}:${f.line}".`,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// v2.1.0 (A7) — "Questions saved" read 0 for ever: a lane that SKIPPED a
|
|
676
|
+
// question because a config value answered it wrote no `by=config` line. At
|
|
677
|
+
// FINISH, with habits on, each of this lane's points whose config key is SET in
|
|
678
|
+
// the override (and not `ask`) and that has no ASK line in the run is named once.
|
|
679
|
+
// It never blocks; habits off → nothing (zero bytes).
|
|
680
|
+
function configNudge(deps, claudeDir, v, r) {
|
|
681
|
+
if (!deps.readOverride || !v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) return null;
|
|
682
|
+
let H;
|
|
683
|
+
try { H = require("./habit.js"); } catch (_) { return null; }
|
|
684
|
+
let map = {};
|
|
685
|
+
try { map = deps.readOverride(claudeDir).map || {}; } catch (_) { return null; }
|
|
686
|
+
let mode = "off";
|
|
687
|
+
try { mode = H.habitsMode(map); } catch (_) {}
|
|
688
|
+
if (mode === "off") return null;
|
|
689
|
+
const file = path.join(path.dirname(claudeDir), r.trace_path);
|
|
690
|
+
let txt = "";
|
|
691
|
+
try { txt = fs.readFileSync(file, "utf8"); } catch (_) { return null; }
|
|
692
|
+
const tok = (/(?:^|\/)run-([a-z0-9]+)-/.exec(r.trace_path) || [])[1];
|
|
693
|
+
if (!tok) return null;
|
|
694
|
+
const lane = tok === "orc" || tok === "ultra" ? "orc" : `orc-${tok}`;
|
|
695
|
+
const done = new Set();
|
|
696
|
+
for (const m of txt.matchAll(/\] \S+\s+ASK (\S+) ::/g)) done.add(m[1]);
|
|
697
|
+
const points = [];
|
|
698
|
+
for (const [qid, pt] of Object.entries(H.ASK_POINTS)) {
|
|
699
|
+
if (!pt.key || !(pt.lanes || []).includes(lane) || done.has(qid)) continue;
|
|
700
|
+
const val = map[pt.key];
|
|
701
|
+
if (val === undefined || val === null || String(val) === "ask") continue;
|
|
702
|
+
const opt = Object.entries(pt.values || {}).find(([, x]) => String(x) === String(val));
|
|
703
|
+
points.push({ qid, key: pt.key, value: String(val), option: opt ? opt[0] : null });
|
|
704
|
+
}
|
|
705
|
+
if (!points.length) return null;
|
|
706
|
+
return {
|
|
707
|
+
points,
|
|
708
|
+
line: `habits: ${mode} — your config answered ${points.map((p) => `${p.qid} (${p.key}: ${p.value})`).join(", ")} and this run has no ASK line for it. If the run reached that question, write ONE packet (with \`run: ${path.basename(r.trace_path)}\`): one ASK event each, tail "offered=<the options> rec=<o> chose=<the option your config picked> by=config". Not reached this run → no event.`,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function traceCmd(deps) {
|
|
713
|
+
const pos = deps.positionals();
|
|
714
|
+
const json = deps.wantsJson();
|
|
715
|
+
const fail = (code, state, reason, extra) => {
|
|
716
|
+
if (json) return deps.emitJson(Object.assign({ ok: false, state, reason }, extra || {}), code);
|
|
717
|
+
console.error(`trace write: ${reason}`);
|
|
718
|
+
process.exit(code);
|
|
719
|
+
};
|
|
720
|
+
if (pos[1] !== "write") return fail(1, "usage", USAGE);
|
|
721
|
+
const src = deps.flag("--packet");
|
|
722
|
+
if (src === undefined) return fail(1, "usage", USAGE);
|
|
723
|
+
let text;
|
|
724
|
+
try {
|
|
725
|
+
text = src === true || src === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(String(src)), "utf8");
|
|
726
|
+
} catch (e) {
|
|
727
|
+
return fail(1, "unreadable", `cannot read the packet (${e.code || e.message})`);
|
|
728
|
+
}
|
|
729
|
+
let v;
|
|
730
|
+
try {
|
|
731
|
+
v = validatePacket(parsePacket(text), deps.TRACE_VERBS);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
if (!(e instanceof PacketError)) throw e;
|
|
734
|
+
return fail(2, "invalid", `${e.message} — nothing written`, { errors: e.errors || [e.message] });
|
|
735
|
+
}
|
|
736
|
+
// An ASK is checked against the habit registry AS IT ARRIVES (eval E2): a live lane
|
|
737
|
+
// wrote its own option names and extra text, and the habit engine then counted
|
|
738
|
+
// nothing. An invalid ASK is kept OUT of the trace and handed back with the option
|
|
739
|
+
// ids, so the lane resends it in the same turn; the other events are written.
|
|
740
|
+
const askRejected = [];
|
|
741
|
+
let H = null;
|
|
742
|
+
try { H = require("./habit.js"); } catch (_) {}
|
|
743
|
+
// The trace so far, to tell a FOLLOW-UP from a new answer: the same qid again
|
|
744
|
+
// with no new executor SPAWN (the hook writes those, deterministically) since
|
|
745
|
+
// its last ASK is the same entry's next decision (eval E2: `stop` at the commit
|
|
746
|
+
// offer after the review, recorded as quick.q3.offer.review).
|
|
747
|
+
let sofar = "";
|
|
748
|
+
let sofarName = null;
|
|
749
|
+
try {
|
|
750
|
+
const ld = deps.resolveLogDir(deps.resolveClaudeDir());
|
|
751
|
+
let cur = null;
|
|
752
|
+
try { cur = fs.readFileSync(path.join(ld, ".current"), "utf8").trim() || null; } catch (_) {}
|
|
753
|
+
const name = cur || (v.run ? path.basename(v.run).replace(/(\.txt)?$/, ".txt") : null);
|
|
754
|
+
sofarName = name;
|
|
755
|
+
if (name) sofar = fs.readFileSync(path.join(ld, name), "utf8");
|
|
756
|
+
} catch (_) {}
|
|
757
|
+
const followUp = (qid) => {
|
|
758
|
+
const lines = sofar.split(/\r?\n/);
|
|
759
|
+
let last = -1;
|
|
760
|
+
lines.forEach((l, i) => { if (new RegExp(`\\]\\s+\\S+\\s+ASK ${qid.replace(/\./g, "\\.")} ::`).test(l)) last = i; });
|
|
761
|
+
if (last < 0) return false;
|
|
762
|
+
// v2.1.0 (A6): a NEW entry starts at an executor OR a recon SPAWN, or an extra
|
|
763
|
+
// dispatch line. A reviewer SPAWN is part of the same entry (its commit offer
|
|
764
|
+
// after the review is a follow-up), so it is not a boundary.
|
|
765
|
+
return !lines.slice(last + 1).some((l) => /\]\s+hook\s+SPAWN orc-(?:executor|recon)-/.test(l) || /\]\s+\S+\s+EXTRA\s/.test(l));
|
|
766
|
+
};
|
|
767
|
+
if (H) {
|
|
768
|
+
const seen = new Set();
|
|
769
|
+
v.events = v.events.filter((e) => {
|
|
770
|
+
if (e.verb.split(/\s+/)[0] !== "ASK") return true;
|
|
771
|
+
const p = H.parseAskLine(`${e.verb} :: ${e.tail || ""}`);
|
|
772
|
+
if (p && p.ok !== false && (followUp(p.qid) || seen.has(p.qid))) {
|
|
773
|
+
askRejected.push({ qid: p.qid, error: "a follow-up decision in the SAME entry (no new executor dispatch since this question was answered) — record no ASK for it", options: "none" });
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
// eval E2: a live lane invented `q3-review`, `q-exec` — an id the registry
|
|
777
|
+
// does not know counts for nothing, so it is refused with the real ids.
|
|
778
|
+
if (p && p.ok !== false && p.known === false && !(H.HABIT_META_QIDS || []).includes(p.qid)) {
|
|
779
|
+
const tok = (/(?:^|\/)run-([a-z0-9]+)-/.exec(sofarName || "") || [])[1];
|
|
780
|
+
const lane = !tok ? null : tok === "orc" || tok === "ultra" ? "orc" : `orc-${tok}`;
|
|
781
|
+
const ids = Object.keys(H.ASK_POINTS).filter((q) => !lane || (H.ASK_POINTS[q].lanes || []).includes(lane));
|
|
782
|
+
askRejected.push({ qid: p.qid, error: `unknown question id — use one of: ${ids.join(", ")}`, options: "see the id list" });
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
if (p && p.ok !== false) {
|
|
786
|
+
seen.add(p.qid);
|
|
787
|
+
// v2.1.0 (A1): an agent gate is STORED with the canonical agent names.
|
|
788
|
+
const pt = H.ASK_POINTS[p.qid];
|
|
789
|
+
if (pt && pt.open === "agent") {
|
|
790
|
+
const ctx = Object.entries(p.ctx || {}).map(([k, x]) => `${k}=${x}`).join(",");
|
|
791
|
+
e.tail = `offered=${p.offered.join("|")} rec=${p.rec} chose=${p.chose} by=${p.by}` + (p.pre ? ` pre=${p.pre}` : "") + (ctx ? ` ctx=${ctx}` : "");
|
|
792
|
+
}
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
const qid = e.verb.split(/\s+/)[1] || "";
|
|
796
|
+
const pt = H.ASK_POINTS[qid];
|
|
797
|
+
const o = pt ? Object.keys(pt.options || {}) : [];
|
|
798
|
+
askRejected.push({ qid, error: (p && p.error) || "not an ASK line", options: o.length ? o.join("|") : "the agent names you offered (plain ids, no spaces)" });
|
|
799
|
+
return false;
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
// v2.1.0 (E21 D3/D4) — §0 "which review" exists only when the PROJECT names its
|
|
803
|
+
// own review. Live runs wrote REVIEW-WHICH and ASK any.review.which with no rule
|
|
804
|
+
// at all, so both are handed back by name when `orc review policy` says orc.
|
|
805
|
+
const isWhich = (e) => /^REVIEW-WHICH\b/.test(e.verb) || /^ASK any\.review\.which\b/.test(e.verb);
|
|
806
|
+
if (typeof deps.reviewPolicyCompute === "function" && v.events.some(isWhich)) {
|
|
807
|
+
let pol = null;
|
|
808
|
+
try { pol = deps.reviewPolicyCompute(deps.resolveClaudeDir()); } catch (_) {}
|
|
809
|
+
if (pol && pol.policy !== "project")
|
|
810
|
+
v.events = v.events.filter((e) => {
|
|
811
|
+
if (!isWhich(e)) return true;
|
|
812
|
+
askRejected.push({ qid: /^ASK/.test(e.verb) ? "any.review.which" : "REVIEW-WHICH", error: "no project review rule (`orc review policy` says orc) — the which-review question is not asked, so record nothing for it", options: "none" });
|
|
813
|
+
return false;
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
if (askRejected.length && !v.events.length && !v.note) {
|
|
817
|
+
const line = `ASK rejected — nothing written. Resend each as verb "ASK <qid>", tail "offered=<a|b> rec=<o> chose=<o> by=user" with these ids: ${askRejected.map((a) => `${a.qid} (${a.options}) — ${a.error}`).join("; ")}`;
|
|
818
|
+
if (json) return deps.emitJson({ ask_rejected_line: line, ok: true, lines_written: 0, jsonl_written: 0, ask_rejected: askRejected }, 0);
|
|
819
|
+
console.log(line);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const claudeDir = deps.resolveClaudeDir();
|
|
823
|
+
let r;
|
|
824
|
+
try {
|
|
825
|
+
r = writePacket(claudeDir, deps.resolveLogDir(claudeDir), v, renderBlock(v));
|
|
826
|
+
} catch (e) {
|
|
827
|
+
if (!(e instanceof StateError)) return fail(3, "error", `${e.message} — nothing written`);
|
|
828
|
+
return fail(3, "trace-state", `${e.message} — nothing written`);
|
|
829
|
+
}
|
|
830
|
+
const nudge = askNudge(deps, claudeDir, v, r);
|
|
831
|
+
const obsNudge = observeNudge(claudeDir, v, r);
|
|
832
|
+
const whichNudge = reviewWhichNudge(deps, claudeDir, v, r);
|
|
833
|
+
const cfgNudge = configNudge(deps, claudeDir, v, r);
|
|
834
|
+
const phNudge = phaseNudge(v, r, claudeDir);
|
|
835
|
+
// v2.1.0 W7 (DE-20 a) — the opt-in log sweep, AFTER the FINISH packet is on
|
|
836
|
+
// disk. `log_retention_auto: off` → one config lookup, no scan. It never
|
|
837
|
+
// blocks and never changes the exit code (sweep() swallows every error).
|
|
838
|
+
let sweep = null;
|
|
839
|
+
if (typeof deps.logSweep === "function" && v.events.some((e) => e.verb.split(/\s+/)[0] === "FINISH")) {
|
|
840
|
+
try { sweep = deps.logSweep(claudeDir); } catch (_) { sweep = null; }
|
|
841
|
+
}
|
|
842
|
+
// The messages go FIRST: a lane often pipes the answer through `head`.
|
|
843
|
+
const rejLine = askRejected.length
|
|
844
|
+
? `ASK rejected (the other events were written) — resend each as verb "ASK <qid>", tail "offered=<a|b> rec=<o> chose=<o> by=user" with these ids: ${askRejected.map((a) => `${a.qid} (${a.options}) — ${a.error}`).join("; ")}`
|
|
845
|
+
: null;
|
|
846
|
+
const head = Object.assign(
|
|
847
|
+
rejLine ? { ask_rejected_line: rejLine } : {},
|
|
848
|
+
nudge ? { ask_missing_line: nudge.line } : {},
|
|
849
|
+
obsNudge ? { observe_missing_line: obsNudge.line } : {},
|
|
850
|
+
whichNudge ? { review_which_missing_line: whichNudge.line } : {},
|
|
851
|
+
cfgNudge ? { config_missing_line: cfgNudge.line } : {},
|
|
852
|
+
phNudge ? { phases_missing_line: phNudge.line } : {}
|
|
853
|
+
);
|
|
854
|
+
if (json) return deps.emitJson(Object.assign(head, { ok: true }, r, rejLine ? { ask_rejected: askRejected } : {}, nudge ? { ask_missing: nudge } : {}, obsNudge ? { observe_missing: obsNudge } : {}, whichNudge ? { review_which_missing: whichNudge } : {}, cfgNudge ? { config_missing: cfgNudge } : {}, phNudge ? { phases_missing: phNudge } : {}, sweep ? { log_sweep: sweep } : {}), 0);
|
|
855
|
+
if (rejLine) console.log(rejLine);
|
|
856
|
+
if (nudge) console.log(nudge.line);
|
|
857
|
+
if (obsNudge) console.log(obsNudge.line);
|
|
858
|
+
if (whichNudge) console.log(whichNudge.line);
|
|
859
|
+
if (cfgNudge) console.log(cfgNudge.line);
|
|
860
|
+
if (phNudge) console.log(phNudge.line);
|
|
861
|
+
if (sweep) console.log(sweep.line);
|
|
862
|
+
console.log(
|
|
863
|
+
`trace write: +${r.lines_written} lines, +${r.jsonl_written} jsonl → ${r.trace_path}` +
|
|
864
|
+
(r.renamed ? " (renamed the bootstrap file)" : "") +
|
|
865
|
+
(r.repointed ? " (.current repointed)" : "")
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
module.exports = { parsePacket, validatePacket, renderBlock, writePacket, traceCmd, kebab, PacketError, StateError, USAGE };
|