@ordewell/core 0.9.0 → 0.10.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/dist/{IFileSystem-DpaaFdit.d.ts → IFileSystem-BCJBHFwB.d.ts} +3 -1
- package/dist/{IFileSystem-_m7HdoxJ.d.mts → IFileSystem-Bj6fy1cG.d.mts} +3 -1
- package/dist/{IRunner-BiQio8gJ.d.ts → IRunner-C7_SSpzG.d.ts} +614 -77
- package/dist/{IRunner-CxiymkP_.d.mts → IRunner-hWAJC4hg.d.mts} +614 -77
- package/dist/ModeResolver-m1d8Cb-j.d.mts +35 -0
- package/dist/ModeResolver-u5ZhRC8x.d.ts +35 -0
- package/dist/{Task-1Q19y7sZ.d.mts → Task-CxwnCJLi.d.mts} +346 -127
- package/dist/{Task-1Q19y7sZ.d.ts → Task-CxwnCJLi.d.ts} +346 -127
- package/dist/{chunk-UUU46PVQ.mjs → chunk-2IIRSBEQ.mjs} +109 -142
- package/dist/chunk-2IIRSBEQ.mjs.map +1 -0
- package/dist/{chunk-QUARDKVT.mjs → chunk-CH5IGWFS.mjs} +546 -967
- package/dist/chunk-CH5IGWFS.mjs.map +1 -0
- package/dist/chunk-DJAPDO6E.mjs +1358 -0
- package/dist/chunk-DJAPDO6E.mjs.map +1 -0
- package/dist/chunk-DNFT4SQG.mjs +97 -0
- package/dist/chunk-DNFT4SQG.mjs.map +1 -0
- package/dist/index.d.mts +3204 -3926
- package/dist/index.d.ts +3204 -3926
- package/dist/index.js +22283 -16472
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20220 -14690
- package/dist/index.mjs.map +1 -1
- package/dist/parsing.d.mts +55 -3
- package/dist/parsing.d.ts +55 -3
- package/dist/parsing.js +311 -124
- package/dist/parsing.js.map +1 -1
- package/dist/parsing.mjs +86 -6
- package/dist/parsing.mjs.map +1 -1
- package/dist/plan-utils-B6yJdpgi.d.ts +2902 -0
- package/dist/plan-utils-CKXdD1AD.d.mts +2902 -0
- package/dist/plan-utils.d.mts +4 -5
- package/dist/plan-utils.d.ts +4 -5
- package/dist/plan-utils.js +471 -86
- package/dist/plan-utils.js.map +1 -1
- package/dist/plan-utils.mjs +68 -19
- package/dist/testing.d.mts +22 -4
- package/dist/testing.d.ts +22 -4
- package/dist/testing.js +46 -9
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +47 -11
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -10
- package/skills/tdd/SKILL.md +0 -1
- package/dist/ModeResolver-DE-o2lRF.d.mts +0 -48
- package/dist/ModeResolver-EWlpmVAy.d.ts +0 -48
- package/dist/chunk-2M4X3VB7.mjs +0 -432
- package/dist/chunk-2M4X3VB7.mjs.map +0 -1
- package/dist/chunk-H5O74L77.mjs +0 -185
- package/dist/chunk-H5O74L77.mjs.map +0 -1
- package/dist/chunk-QUARDKVT.mjs.map +0 -1
- package/dist/chunk-UUU46PVQ.mjs.map +0 -1
- package/dist/chunk-V3JHS7AX.mjs +0 -27
- package/dist/chunk-V3JHS7AX.mjs.map +0 -1
- package/dist/order-labels.d.mts +0 -30
- package/dist/order-labels.d.ts +0 -30
- package/dist/order-labels.js +0 -54
- package/dist/order-labels.js.map +0 -1
- package/dist/order-labels.mjs +0 -13
- package/dist/order-labels.mjs.map +0 -1
- package/dist/parsing-CZdp2p_n.d.ts +0 -154
- package/dist/parsing-GgUJoYKs.d.mts +0 -154
- package/dist/plan-utils-6rUr7ZOx.d.ts +0 -1222
- package/dist/plan-utils-P3PIEwNa.d.mts +0 -1222
package/dist/parsing.d.mts
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
import './
|
|
3
|
-
|
|
1
|
+
import { $ as RunnerId, ac as Task } from './Task-CxwnCJLi.mjs';
|
|
2
|
+
import { R as RunnerModeInfo } from './ModeResolver-m1d8Cb-j.mjs';
|
|
3
|
+
|
|
4
|
+
declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
|
|
5
|
+
|
|
6
|
+
/** Thrown when an LLM plan response cannot be parsed into a valid plan. Carries the raw text so callers can log it or retry. */
|
|
7
|
+
declare class PlanParseError extends Error {
|
|
8
|
+
readonly raw: string;
|
|
9
|
+
/**
|
|
10
|
+
* True when the reply was cut off mid-object (output-token limit) rather
|
|
11
|
+
* than malformed — the two need different repairs: a truncated emission will
|
|
12
|
+
* be cut at the same point again unless the retry frees context or asks for
|
|
13
|
+
* terser output.
|
|
14
|
+
*/
|
|
15
|
+
readonly truncated: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True when the JSON parsed fine and a *shape* rule rejected it. The generic
|
|
18
|
+
* "could not be parsed as JSON" corrective is a lie for these, so the model
|
|
19
|
+
* re-sends the same object; a semantic rejection must be repaired by naming
|
|
20
|
+
* the rule it broke.
|
|
21
|
+
*/
|
|
22
|
+
readonly semantic: boolean;
|
|
23
|
+
constructor(message: string, raw: string, opts?: {
|
|
24
|
+
truncated?: boolean;
|
|
25
|
+
semantic?: boolean;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Pull the JSON object out of an LLM response. Strips markdown code fences and
|
|
30
|
+
* `<think>` blocks, prefers the object containing a `tasks` key, and tolerates a
|
|
31
|
+
* prose preamble and trailing prose. Returns the fence-stripped text unchanged
|
|
32
|
+
* if no `{` is present so the caller's JSON.parse produces a clear failure.
|
|
33
|
+
*/
|
|
34
|
+
declare function extractJsonObject(raw: string): string;
|
|
35
|
+
|
|
36
|
+
/** A task as seen in a still-streaming plan: its title (possibly partial) and whether its object has closed. */
|
|
37
|
+
interface PartialPlanTask {
|
|
38
|
+
title: string;
|
|
39
|
+
status: 'streaming' | 'complete';
|
|
40
|
+
/** Assigned model label, once it has streamed in — for the live row's chip. */
|
|
41
|
+
model?: string;
|
|
42
|
+
/** Set once `"readOnly": true` has streamed in — for the live row's chip. */
|
|
43
|
+
readOnly?: true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse a partial (still-streaming) plan-JSON string into an ordered list of task
|
|
47
|
+
* rows for the live progress view. Pure and lenient: it walks the characters of the
|
|
48
|
+
* `tasks` array, emitting one row per task object once it has opened, with the title
|
|
49
|
+
* read incrementally and the status flipping to `complete` when the object's brace
|
|
50
|
+
* closes. The currently-streaming object stays `streaming` even with a partial title.
|
|
51
|
+
* The React view is a thin renderer over this — it never parses JSON itself.
|
|
52
|
+
*/
|
|
53
|
+
declare function parsePartialPlan(partial: string): PartialPlanTask[];
|
|
54
|
+
|
|
55
|
+
export { type PartialPlanTask, PlanParseError, extractJsonObject, parsePartialPlan, parsePlanJson };
|
package/dist/parsing.d.ts
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
import './
|
|
3
|
-
|
|
1
|
+
import { $ as RunnerId, ac as Task } from './Task-CxwnCJLi.js';
|
|
2
|
+
import { R as RunnerModeInfo } from './ModeResolver-u5ZhRC8x.js';
|
|
3
|
+
|
|
4
|
+
declare function parsePlanJson(raw: string, runners: RunnerId[], runnerModes?: Record<RunnerId, RunnerModeInfo[]>, autonomousDefault?: boolean): Task[];
|
|
5
|
+
|
|
6
|
+
/** Thrown when an LLM plan response cannot be parsed into a valid plan. Carries the raw text so callers can log it or retry. */
|
|
7
|
+
declare class PlanParseError extends Error {
|
|
8
|
+
readonly raw: string;
|
|
9
|
+
/**
|
|
10
|
+
* True when the reply was cut off mid-object (output-token limit) rather
|
|
11
|
+
* than malformed — the two need different repairs: a truncated emission will
|
|
12
|
+
* be cut at the same point again unless the retry frees context or asks for
|
|
13
|
+
* terser output.
|
|
14
|
+
*/
|
|
15
|
+
readonly truncated: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* True when the JSON parsed fine and a *shape* rule rejected it. The generic
|
|
18
|
+
* "could not be parsed as JSON" corrective is a lie for these, so the model
|
|
19
|
+
* re-sends the same object; a semantic rejection must be repaired by naming
|
|
20
|
+
* the rule it broke.
|
|
21
|
+
*/
|
|
22
|
+
readonly semantic: boolean;
|
|
23
|
+
constructor(message: string, raw: string, opts?: {
|
|
24
|
+
truncated?: boolean;
|
|
25
|
+
semantic?: boolean;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Pull the JSON object out of an LLM response. Strips markdown code fences and
|
|
30
|
+
* `<think>` blocks, prefers the object containing a `tasks` key, and tolerates a
|
|
31
|
+
* prose preamble and trailing prose. Returns the fence-stripped text unchanged
|
|
32
|
+
* if no `{` is present so the caller's JSON.parse produces a clear failure.
|
|
33
|
+
*/
|
|
34
|
+
declare function extractJsonObject(raw: string): string;
|
|
35
|
+
|
|
36
|
+
/** A task as seen in a still-streaming plan: its title (possibly partial) and whether its object has closed. */
|
|
37
|
+
interface PartialPlanTask {
|
|
38
|
+
title: string;
|
|
39
|
+
status: 'streaming' | 'complete';
|
|
40
|
+
/** Assigned model label, once it has streamed in — for the live row's chip. */
|
|
41
|
+
model?: string;
|
|
42
|
+
/** Set once `"readOnly": true` has streamed in — for the live row's chip. */
|
|
43
|
+
readOnly?: true;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse a partial (still-streaming) plan-JSON string into an ordered list of task
|
|
47
|
+
* rows for the live progress view. Pure and lenient: it walks the characters of the
|
|
48
|
+
* `tasks` array, emitting one row per task object once it has opened, with the title
|
|
49
|
+
* read incrementally and the status flipping to `complete` when the object's brace
|
|
50
|
+
* closes. The currently-streaming object stays `streaming` even with a partial title.
|
|
51
|
+
* The React view is a thin renderer over this — it never parses JSON itself.
|
|
52
|
+
*/
|
|
53
|
+
declare function parsePartialPlan(partial: string): PartialPlanTask[];
|
|
54
|
+
|
|
55
|
+
export { type PartialPlanTask, PlanParseError, extractJsonObject, parsePartialPlan, parsePlanJson };
|
package/dist/parsing.js
CHANGED
|
@@ -34,6 +34,11 @@ var import_uuid = require("uuid");
|
|
|
34
34
|
var SKILL_NAME_PATTERN = "[a-z0-9][a-z0-9_-]*";
|
|
35
35
|
var SKILL_TOKEN = new RegExp(`(^|\\s)\\/(${SKILL_NAME_PATTERN})([,.!?;:]*)(?=\\s|$)`, "gi");
|
|
36
36
|
|
|
37
|
+
// src/services/TaskRefs.ts
|
|
38
|
+
function taskOrderLabel(task, parent) {
|
|
39
|
+
return parent ? `${parent.label ?? parent.order}.${task.order}` : String(task.order);
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
// src/models/Task.ts
|
|
38
43
|
function createTask(overrides = {}) {
|
|
39
44
|
const skills = skillNames(overrides.skills);
|
|
@@ -50,15 +55,16 @@ function createTask(overrides = {}) {
|
|
|
50
55
|
subtasks: overrides.subtasks ?? [],
|
|
51
56
|
verdict: overrides.verdict,
|
|
52
57
|
outputSummary: overrides.outputSummary,
|
|
58
|
+
...overrides.attemptUsage ? { attemptUsage: structuredClone(overrides.attemptUsage) } : {},
|
|
53
59
|
assignedModel: overrides.assignedModel,
|
|
54
60
|
assignedRunner: overrides.assignedRunner ?? "claude-code",
|
|
55
|
-
thinkingEffort: overrides.thinkingEffort,
|
|
56
61
|
taskMode: overrides.taskMode ?? "build",
|
|
57
62
|
autonomy: overrides.autonomy,
|
|
58
63
|
sliceType: overrides.sliceType,
|
|
59
64
|
userStoriesCovered: overrides.userStoriesCovered,
|
|
60
65
|
...opsFlag(overrides.ops, overrides.type) ? { ops: true } : {},
|
|
61
|
-
...skills ? { skills } : {}
|
|
66
|
+
...skills ? { skills } : {},
|
|
67
|
+
...overrides.addedBy === "supervisor" ? { addedBy: "supervisor" } : {}
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
var SKILL_NAME = new RegExp(`^${SKILL_NAME_PATTERN}$`);
|
|
@@ -70,9 +76,23 @@ function skillNames(value) {
|
|
|
70
76
|
const names = [...new Set(value.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(isSkillName))];
|
|
71
77
|
return names.length > 0 ? names : void 0;
|
|
72
78
|
}
|
|
79
|
+
function inheritedSkills(chosen, inherited) {
|
|
80
|
+
if (Array.isArray(chosen) && chosen.length === 0) return void 0;
|
|
81
|
+
return skillNames(chosen) ?? skillNames(inherited);
|
|
82
|
+
}
|
|
73
83
|
function opsFlag(value, type = "ai") {
|
|
74
84
|
return value === true && type === "ai" ? true : void 0;
|
|
75
85
|
}
|
|
86
|
+
function flattenTasksWithParents(tasks) {
|
|
87
|
+
const rows = [];
|
|
88
|
+
const walk = (t, parent, parentLabel) => {
|
|
89
|
+
const label = taskOrderLabel(t, parent ? { order: parent.order, label: parentLabel } : void 0);
|
|
90
|
+
rows.push({ task: t, parent, label, ref: `#${label}` });
|
|
91
|
+
for (const sub of t.subtasks ?? []) walk(sub, t, label);
|
|
92
|
+
};
|
|
93
|
+
for (const t of tasks) walk(t, null);
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
76
96
|
|
|
77
97
|
// src/services/JsonExtractor.ts
|
|
78
98
|
var PLAN_ENVELOPE_KEY = "tasks";
|
|
@@ -103,12 +123,12 @@ var PlanParseError = class extends Error {
|
|
|
103
123
|
function stripModelNoise(raw) {
|
|
104
124
|
return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/```json\s*/gi, "").replace(/```\s*/g, "").trim();
|
|
105
125
|
}
|
|
106
|
-
function balancedObjectAt(
|
|
126
|
+
function balancedObjectAt(text2, start) {
|
|
107
127
|
let depth = 0;
|
|
108
128
|
let inString = false;
|
|
109
129
|
let escaped = false;
|
|
110
|
-
for (let i = start; i <
|
|
111
|
-
const ch =
|
|
130
|
+
for (let i = start; i < text2.length; i++) {
|
|
131
|
+
const ch = text2[i];
|
|
112
132
|
if (inString) {
|
|
113
133
|
if (escaped) escaped = false;
|
|
114
134
|
else if (ch === "\\") escaped = true;
|
|
@@ -119,10 +139,10 @@ function balancedObjectAt(text, start) {
|
|
|
119
139
|
else if (ch === "{") depth++;
|
|
120
140
|
else if (ch === "}") {
|
|
121
141
|
depth--;
|
|
122
|
-
if (depth === 0) return { json:
|
|
142
|
+
if (depth === 0) return { json: text2.slice(start, i + 1), balanced: true };
|
|
123
143
|
}
|
|
124
144
|
}
|
|
125
|
-
return { json:
|
|
145
|
+
return { json: text2.slice(start), balanced: false };
|
|
126
146
|
}
|
|
127
147
|
function extractObjectsWithKey(raw, key) {
|
|
128
148
|
const stripped = stripModelNoise(raw);
|
|
@@ -216,6 +236,9 @@ function escapeControlCharsInStrings(json) {
|
|
|
216
236
|
return out;
|
|
217
237
|
}
|
|
218
238
|
|
|
239
|
+
// src/services/PlannedTask.ts
|
|
240
|
+
var import_uuid2 = require("uuid");
|
|
241
|
+
|
|
219
242
|
// src/services/ModeResolver.ts
|
|
220
243
|
function resolveDefaultMode(modes, autonomousDefault = true) {
|
|
221
244
|
if (!modes || modes.length === 0) return void 0;
|
|
@@ -223,32 +246,241 @@ function resolveDefaultMode(modes, autonomousDefault = true) {
|
|
|
223
246
|
const tagged = modes.find((m) => m[tag]);
|
|
224
247
|
if (tagged) return tagged.id;
|
|
225
248
|
for (let i = modes.length - 1; i >= 0; i--) {
|
|
226
|
-
if (modes[i].id !== "plan") return modes[i].id;
|
|
249
|
+
if (!modes[i].readOnly && modes[i].id !== "plan") return modes[i].id;
|
|
227
250
|
}
|
|
228
251
|
return void 0;
|
|
229
252
|
}
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
253
|
+
function taggedReadOnlyMode(modes) {
|
|
254
|
+
return modes?.find((m) => m.readOnly)?.id;
|
|
255
|
+
}
|
|
256
|
+
function readOnlyMode(modes) {
|
|
257
|
+
const mode = modes?.find((m) => m.readOnly);
|
|
258
|
+
return mode && !mode.unavailable ? mode.id : void 0;
|
|
259
|
+
}
|
|
260
|
+
function unavailableModeReason(modes, id) {
|
|
261
|
+
return id === void 0 ? void 0 : modes?.find((m) => m.id === id)?.unavailable;
|
|
262
|
+
}
|
|
263
|
+
function unavailableModeError(runner, id, reason) {
|
|
264
|
+
return `Mode "${id}" of runner "${runner}" is unavailable on this host: ${reason}`;
|
|
265
|
+
}
|
|
266
|
+
function plannedMode(runner, runnerModes, { readOnly, current }, autonomousDefault = true) {
|
|
267
|
+
if (!runnerModes) return { ok: true, mode: void 0 };
|
|
268
|
+
const modes = runnerModes[runner] ?? [];
|
|
269
|
+
const readOnlyId = readOnlyMode(modes);
|
|
270
|
+
if (readOnly === true) {
|
|
271
|
+
if (readOnlyId) return { ok: true, mode: readOnlyId };
|
|
272
|
+
const able = readOnlyRunners(runnerModes);
|
|
273
|
+
const reason2 = unavailableModeReason(modes, taggedReadOnlyMode(modes));
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `Runner "${runner}" has no read-only mode${reason2 ? " available on this host" : ""} \u2014 assign a read-only task to ${able.length > 0 ? `one of: ${able.join(", ")}` : "no runner: none of the enabled runners has one"}, or drop "readOnly"${reason2 ? `. ${reason2}` : ""}`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const keep = current !== void 0 && modes.some((m) => m.id === current) && !(readOnly === false && current === taggedReadOnlyMode(modes));
|
|
280
|
+
const mode = keep ? current : resolveDefaultMode(modes, autonomousDefault);
|
|
281
|
+
const reason = unavailableModeReason(modes, mode);
|
|
282
|
+
if (mode !== void 0 && reason) return { ok: false, error: unavailableModeError(runner, mode, reason) };
|
|
283
|
+
return { ok: true, mode };
|
|
284
|
+
}
|
|
285
|
+
function readOnlyRunners(runnerModes) {
|
|
286
|
+
return Object.entries(runnerModes).filter(([, ms]) => readOnlyMode(ms)).map(([id]) => id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/services/ModelAllowlistResolver.ts
|
|
290
|
+
function effectiveAllowlist(allowlist, runner, modelsByRunner) {
|
|
291
|
+
if (!allowlist || allowlist.length === 0) return void 0;
|
|
292
|
+
if (!modelsByRunner) return allowlist;
|
|
293
|
+
const own = new Set((modelsByRunner[runner] ?? []).map((m) => m.modelId));
|
|
294
|
+
const elsewhere = /* @__PURE__ */ new Set();
|
|
295
|
+
for (const [other, models] of Object.entries(modelsByRunner)) {
|
|
296
|
+
if (other === runner) continue;
|
|
297
|
+
for (const m of models ?? []) elsewhere.add(m.modelId);
|
|
298
|
+
}
|
|
299
|
+
const kept = allowlist.filter((id) => own.has(id) || !elsewhere.has(id));
|
|
300
|
+
return kept.length > 0 ? kept : void 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/services/TaskEditValidator.ts
|
|
304
|
+
function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
|
|
305
|
+
if (!catalog) return { ok: true };
|
|
306
|
+
if (assignedModel) {
|
|
307
|
+
const models = catalog.modelsByRunner[runner];
|
|
308
|
+
if (models) {
|
|
309
|
+
const modelId = assignedModel.modelId;
|
|
310
|
+
const allowed = effectiveAllowlist(catalog.perRunnerAllowlist?.[runner], runner, catalog.modelsByRunner);
|
|
311
|
+
if (!models.some((m) => m.modelId === modelId) && !allowed?.includes(modelId)) {
|
|
312
|
+
return { ok: false, error: `Runner "${runner}" does not offer model "${modelId}"` };
|
|
313
|
+
}
|
|
314
|
+
if (allowed && !allowed.includes(modelId)) {
|
|
315
|
+
return { ok: false, error: `Model "${modelId}" is excluded by the allowlist for runner "${runner}"` };
|
|
316
|
+
}
|
|
247
317
|
}
|
|
248
|
-
return rawMode;
|
|
249
318
|
}
|
|
250
|
-
|
|
251
|
-
|
|
319
|
+
if (taskMode !== void 0) {
|
|
320
|
+
const modes = catalog.runnerModes[runner];
|
|
321
|
+
if (modes && modes.length > 0 && !modes.some((m) => m.id === taskMode)) {
|
|
322
|
+
return { ok: false, error: `Runner "${runner}" has no task mode "${taskMode}" \u2014 valid modes: ${modes.map((m) => m.id).join(", ")}` };
|
|
323
|
+
}
|
|
324
|
+
const unavailable = unavailableModeReason(modes, taskMode);
|
|
325
|
+
if (unavailable) return { ok: false, error: `Mode "${taskMode}" of runner "${runner}" is unavailable on this host: ${unavailable}` };
|
|
326
|
+
}
|
|
327
|
+
return { ok: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/services/PlannedTask.ts
|
|
331
|
+
var MODE_NOT_THE_PLANNERS = `"taskMode" is the user's to choose \u2014 set "readOnly": true on a task that must not change files, and leave it out otherwise`;
|
|
332
|
+
var MANUAL_NOT_READ_ONLY = '"readOnly" is for AI tasks \u2014 a manual task runs in no mode';
|
|
333
|
+
var OPS_NOT_READ_ONLY = `"readOnly" is not for an ops task \u2014 it runs commands, in the session's mode, and a read-only mode refuses them`;
|
|
334
|
+
function isRecord(value) {
|
|
335
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
336
|
+
}
|
|
337
|
+
function text(value) {
|
|
338
|
+
return typeof value === "string" || typeof value === "number" ? String(value) : void 0;
|
|
339
|
+
}
|
|
340
|
+
function opsPlacementRefusal(type, isSubtask) {
|
|
341
|
+
if (type !== "ai") return "is a manual task \u2014 only an AI task can be an ops task";
|
|
342
|
+
if (isSubtask) return "is a subtask, which runs with its parent \u2014 only a top-level task can be an ops task";
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
function taskWhere(title, parentTitle) {
|
|
346
|
+
return parentTitle !== void 0 ? `subtask "${title}" of "${parentTitle}"` : `Task "${title}"`;
|
|
347
|
+
}
|
|
348
|
+
var skillsAsked = /* @__PURE__ */ new WeakMap();
|
|
349
|
+
function modelOf(raw, from) {
|
|
350
|
+
const effort = text(raw.thinkingEffort);
|
|
351
|
+
if (isRecord(raw.assignedModel)) {
|
|
352
|
+
const m = raw.assignedModel;
|
|
353
|
+
return {
|
|
354
|
+
modelId: text(m.modelId) ?? "",
|
|
355
|
+
modelLabel: text(m.modelLabel) ?? "",
|
|
356
|
+
thinkingEffort: text(m.thinkingEffort) ?? effort
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return from && effort !== void 0 ? { ...from, thinkingEffort: effort } : from;
|
|
360
|
+
}
|
|
361
|
+
function plannedTask(spec, catalog, at = {}) {
|
|
362
|
+
const raw = isRecord(spec) ? spec : {};
|
|
363
|
+
const { parent, from, freshIds } = at;
|
|
364
|
+
const base = from?.fields ?? {};
|
|
365
|
+
const errors = [];
|
|
366
|
+
const ignored = [];
|
|
367
|
+
const renamed = /* @__PURE__ */ new Map();
|
|
368
|
+
const title = text(raw.title) ?? "";
|
|
369
|
+
const where = taskWhere(title, parent?.title);
|
|
370
|
+
const plannerId = text(raw.id);
|
|
371
|
+
const id = freshIds || !plannerId ? (0, import_uuid2.v4)() : plannerId;
|
|
372
|
+
if (freshIds && plannerId) renamed.set(plannerId, id);
|
|
373
|
+
const type = raw.type === "user" || raw.type === "ai" ? raw.type : base.type ?? "ai";
|
|
374
|
+
const ai = type === "ai";
|
|
375
|
+
const assignedRunner = text(raw.assignedRunner) ?? base.assignedRunner ?? catalog.runners[0] ?? "claude-code";
|
|
376
|
+
const description = text(raw.description) ?? base.description ?? title;
|
|
377
|
+
if (raw.ops === true && !ai) errors.push({ taskId: id, field: "ops", message: `${where} ${opsPlacementRefusal(type, !!parent)}` });
|
|
378
|
+
if (raw.taskMode !== void 0) ignored.push({ taskId: id, field: "taskMode", message: `${where}: ${MODE_NOT_THE_PLANNERS}` });
|
|
379
|
+
if (!ai && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${MANUAL_NOT_READ_ONLY}` });
|
|
380
|
+
const ops = raw.ops !== void 0 ? raw.ops === true : base.ops;
|
|
381
|
+
if (ai && ops && raw.readOnly === true) ignored.push({ taskId: id, field: "readOnly", message: `${where}: ${OPS_NOT_READ_ONLY}` });
|
|
382
|
+
const asked = typeof raw.readOnly === "boolean" ? raw.readOnly : void 0;
|
|
383
|
+
const readOnly = ai && !ops && (asked ?? from?.readOnly ?? parent?.readOnly ?? false);
|
|
384
|
+
let taskMode = base.taskMode;
|
|
385
|
+
if (ai && catalog.runners.includes(assignedRunner)) {
|
|
386
|
+
const mode = plannedMode(assignedRunner, catalog.runnerModes, { readOnly, current: base.taskMode }, catalog.autonomousDefault ?? true);
|
|
387
|
+
if (!mode.ok) errors.push({ taskId: id, field: "readOnly", message: `${where}: ${mode.error}` });
|
|
388
|
+
else if (mode.mode !== void 0) taskMode = mode.mode;
|
|
389
|
+
}
|
|
390
|
+
if (taskMode === void 0 && readOnly && !catalog.runnerModes) taskMode = "plan";
|
|
391
|
+
const assignedModel = ai ? modelOf(raw, base.assignedModel) : void 0;
|
|
392
|
+
if (assignedModel && isRecord(raw.assignedModel) && catalog.models && catalog.runners.includes(assignedRunner)) {
|
|
393
|
+
const validity = checkModelAndModeValidity(assignedRunner, assignedModel, void 0, catalog.models);
|
|
394
|
+
if (!validity.ok) errors.push({ taskId: id, field: "assignedModel", message: `${where}: ${validity.error}` });
|
|
395
|
+
}
|
|
396
|
+
const slice = (value) => value === "AFK" || value === "HITL" ? value : void 0;
|
|
397
|
+
const autonomy = ai ? slice(raw.autonomy) ?? base.autonomy ?? parent?.autonomy : void 0;
|
|
398
|
+
const sliceType = slice(raw.sliceType) ?? base.sliceType ?? (ai ? parent?.sliceType : void 0);
|
|
399
|
+
const subtasks = [];
|
|
400
|
+
if (Array.isArray(raw.subtasks)) {
|
|
401
|
+
for (const s of raw.subtasks) {
|
|
402
|
+
const child = plannedTask(s, catalog, { parent: { title, sliceType, autonomy, readOnly }, freshIds });
|
|
403
|
+
subtasks.push(child.task);
|
|
404
|
+
errors.push(...child.errors);
|
|
405
|
+
ignored.push(...child.ignored);
|
|
406
|
+
for (const [was, now] of child.renamed) renamed.set(was, now);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const task = createTask({
|
|
410
|
+
id,
|
|
411
|
+
order: Number(raw.order ?? 0),
|
|
412
|
+
title,
|
|
413
|
+
description,
|
|
414
|
+
type,
|
|
415
|
+
dependencies: Array.isArray(raw.dependencies) ? raw.dependencies.map(String) : base.dependencies ?? [],
|
|
416
|
+
prompt: text(raw.prompt) || base.prompt || (ai ? description || title || void 0 : void 0),
|
|
417
|
+
userSteps: Array.isArray(raw.userSteps) ? raw.userSteps.map((s) => isRecord(s) ? s : {}).map((s) => ({ order: Number(s.order ?? 0), instruction: String(s.instruction ?? ""), completed: false })) : base.userSteps,
|
|
418
|
+
subtasks,
|
|
419
|
+
assignedModel,
|
|
420
|
+
assignedRunner,
|
|
421
|
+
taskMode,
|
|
422
|
+
autonomy,
|
|
423
|
+
sliceType,
|
|
424
|
+
userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : base.userStoriesCovered,
|
|
425
|
+
ops,
|
|
426
|
+
skills: from ? inheritedSkills(raw.skills, base.skills) : skillNames(raw.skills)
|
|
427
|
+
});
|
|
428
|
+
if (Array.isArray(raw.skills)) skillsAsked.set(task, raw.skills);
|
|
429
|
+
return { task, errors, ignored, renamed };
|
|
430
|
+
}
|
|
431
|
+
function checkPlanInvariants(tasks, runners) {
|
|
432
|
+
const rows = flattenTasksWithParents(tasks);
|
|
433
|
+
const errors = [];
|
|
434
|
+
const seen = /* @__PURE__ */ new Set();
|
|
435
|
+
for (const { task: t, parent } of rows) {
|
|
436
|
+
const where = taskWhere(t.title, parent?.title);
|
|
437
|
+
const fail = (field, message) => errors.push({ taskId: t.id, field, message });
|
|
438
|
+
if (seen.has(t.id)) fail("id", `${where} has id "${t.id}", which another task in the plan has too: every task, subtasks included, needs an id of its own`);
|
|
439
|
+
seen.add(t.id);
|
|
440
|
+
if (t.autonomy === "AFK" && t.userSteps && t.userSteps.length > 0) {
|
|
441
|
+
fail("userSteps", `${where} has autonomy "AFK" but contains userSteps. AFK tasks must not have user touchpoints.`);
|
|
442
|
+
}
|
|
443
|
+
if (t.type === "user" && t.sliceType && t.sliceType !== "HITL") {
|
|
444
|
+
fail("sliceType", `User ${where} has sliceType "${t.sliceType}". User tasks must have sliceType "HITL".`);
|
|
445
|
+
}
|
|
446
|
+
const opsRefused = t.ops ? opsPlacementRefusal(t.type, parent !== null) : null;
|
|
447
|
+
if (opsRefused) fail("ops", `${where} ${opsRefused}`);
|
|
448
|
+
if (t.type !== "user" && !runners.includes(t.assignedRunner)) {
|
|
449
|
+
fail("assignedRunner", `${where} has invalid assignedRunner "${t.assignedRunner}". Expected one of: ${runners.join(", ")}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const ids = new Set(rows.map((r) => r.task.id));
|
|
453
|
+
for (const { task: t, parent } of rows) {
|
|
454
|
+
for (const dep of t.dependencies) {
|
|
455
|
+
if (!ids.has(dep)) errors.push({ taskId: t.id, field: "dependencies", message: `${taskWhere(t.title, parent?.title)} depends on "${dep}", which is not a task in the plan` });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const cycle = findCycle(rows.map((r) => r.task));
|
|
459
|
+
if (cycle) errors.push({ taskId: cycle[0].id, field: "dependencies", message: `Dependency cycle detected: ${[...cycle, cycle[0]].map((t) => `"${t.title}"`).join(" \u2192 ")}` });
|
|
460
|
+
return errors;
|
|
461
|
+
}
|
|
462
|
+
function findCycle(tasks) {
|
|
463
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
464
|
+
const path = [];
|
|
465
|
+
const done = /* @__PURE__ */ new Set();
|
|
466
|
+
const visit = (id) => {
|
|
467
|
+
if (done.has(id)) return null;
|
|
468
|
+
const at = path.indexOf(id);
|
|
469
|
+
if (at >= 0) return path.slice(at);
|
|
470
|
+
path.push(id);
|
|
471
|
+
for (const dep of byId.get(id)?.dependencies ?? []) {
|
|
472
|
+
const hit = visit(dep);
|
|
473
|
+
if (hit) return hit;
|
|
474
|
+
}
|
|
475
|
+
path.pop();
|
|
476
|
+
done.add(id);
|
|
477
|
+
return null;
|
|
478
|
+
};
|
|
479
|
+
for (const t of tasks) {
|
|
480
|
+
const hit = visit(t.id);
|
|
481
|
+
if (hit) return hit.map((id) => byId.get(id));
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
252
484
|
}
|
|
253
485
|
|
|
254
486
|
// src/services/PlanValidator.ts
|
|
@@ -288,117 +520,53 @@ function parsePlanObject(json, raw, runners, runnerModes, autonomousDefault = tr
|
|
|
288
520
|
throw new PlanParseError(first.message, raw, { semantic: first.taskId !== void 0 });
|
|
289
521
|
}
|
|
290
522
|
function validatePlanTasks(obj, runners, runnerModes, autonomousDefault = true) {
|
|
291
|
-
const rawTasks =
|
|
523
|
+
const rawTasks = isRecord2(obj) ? obj.tasks : void 0;
|
|
292
524
|
if (!Array.isArray(rawTasks)) {
|
|
293
525
|
return { ok: false, errors: [{ field: "tasks", message: "Invalid plan: missing tasks array" }] };
|
|
294
526
|
}
|
|
295
527
|
if (rawTasks.length === 0) {
|
|
296
528
|
return { ok: false, errors: [{ field: "tasks", message: "Plan contained no tasks" }] };
|
|
297
529
|
}
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
530
|
+
const planned = rawTasks.map((t) => plannedTask(t, { runners, runnerModes, autonomousDefault }));
|
|
531
|
+
const tasks = planned.map((p) => p.task);
|
|
532
|
+
const errors = [
|
|
533
|
+
...sliceRequired(tasks),
|
|
534
|
+
...planned.flatMap((p) => p.errors),
|
|
535
|
+
...checkPlanInvariants(tasks, runners)
|
|
536
|
+
];
|
|
537
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
538
|
+
const ignored = planned.flatMap((p) => p.ignored);
|
|
539
|
+
const warnings = [
|
|
540
|
+
...ignored.some((i) => i.field === "taskMode") ? [`"taskMode" is not yours to set and was ignored: each task runs in its runner's mode for the user's autonomy level, or its read-only mode when you set "readOnly": true.`] : [],
|
|
541
|
+
...ignored.filter((i) => i.field === "readOnly").map((i) => `${i.message} \u2014 it was ignored.`)
|
|
542
|
+
];
|
|
543
|
+
return warnings.length > 0 ? { ok: true, tasks, warnings } : { ok: true, tasks };
|
|
310
544
|
}
|
|
311
|
-
function
|
|
545
|
+
function isRecord2(value) {
|
|
312
546
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
313
547
|
}
|
|
314
|
-
function
|
|
315
|
-
return
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const title = String(raw.title ?? "");
|
|
322
|
-
const description = String(raw.description ?? "");
|
|
323
|
-
const autonomy = taskType === "ai" && typeof raw.autonomy === "string" && (raw.autonomy === "AFK" || raw.autonomy === "HITL") ? raw.autonomy : taskType === "ai" ? inherited?.autonomy : void 0;
|
|
324
|
-
const sliceType = typeof raw.sliceType === "string" && (raw.sliceType === "HITL" || raw.sliceType === "AFK") ? raw.sliceType : taskType === "ai" ? inherited?.sliceType : void 0;
|
|
325
|
-
const task = createTask({
|
|
326
|
-
id: String(raw.id ?? ""),
|
|
327
|
-
order: Number(raw.order ?? 0),
|
|
328
|
-
title,
|
|
329
|
-
description,
|
|
330
|
-
type: taskType,
|
|
331
|
-
dependencies: Array.isArray(raw.dependencies) ? raw.dependencies.map(String) : [],
|
|
332
|
-
// The planner sometimes omits `prompt` on an AI task while still filling in
|
|
333
|
-
// description/title — same fallback TaskOps.applyTaskOps uses for `add`, so
|
|
334
|
-
// a task never silently ends up unschedulable (getReadyTasks requires prompt).
|
|
335
|
-
prompt: raw.prompt ? String(raw.prompt) : description || title || void 0,
|
|
336
|
-
userSteps: Array.isArray(raw.userSteps) ? raw.userSteps.map(asRecord).map((s) => ({
|
|
337
|
-
order: Number(s.order ?? 0),
|
|
338
|
-
instruction: String(s.instruction ?? ""),
|
|
339
|
-
completed: false
|
|
340
|
-
})) : void 0,
|
|
341
|
-
subtasks: Array.isArray(raw.subtasks) ? raw.subtasks.map((s) => parseTask(asRecord(s), runners, runnerModes, autonomousDefault, { sliceType, autonomy })) : [],
|
|
342
|
-
assignedModel: raw.assignedModel ? {
|
|
343
|
-
modelId: String(raw.assignedModel.modelId ?? ""),
|
|
344
|
-
modelLabel: String(raw.assignedModel.modelLabel ?? ""),
|
|
345
|
-
thinkingEffort: raw.assignedModel.thinkingEffort
|
|
346
|
-
} : void 0,
|
|
347
|
-
assignedRunner,
|
|
348
|
-
thinkingEffort: raw.thinkingEffort ? String(raw.thinkingEffort) : void 0,
|
|
349
|
-
taskMode: resolveTaskMode(
|
|
350
|
-
raw.taskMode !== void 0 ? String(raw.taskMode) : void 0,
|
|
351
|
-
assignedRunner,
|
|
352
|
-
runnerModes,
|
|
353
|
-
autonomousDefault
|
|
354
|
-
),
|
|
355
|
-
autonomy,
|
|
356
|
-
sliceType,
|
|
357
|
-
userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0,
|
|
358
|
-
// A subtask runs with its parent, so only a top-level task carries it (ADR-0020).
|
|
359
|
-
ops: inherited ? void 0 : opsFlag(raw.ops),
|
|
360
|
-
// Not inherited: a subtask's skills are its own.
|
|
361
|
-
skills: skillNames(raw.skills)
|
|
548
|
+
function sliceRequired(tasks) {
|
|
549
|
+
return flattenTasksWithParents(tasks).flatMap(({ task: t, parent }) => {
|
|
550
|
+
const where = taskWhere(t.title, parent?.title);
|
|
551
|
+
const errors = [];
|
|
552
|
+
if (!t.sliceType) errors.push({ taskId: t.id, field: "sliceType", message: `${where} is missing sliceType. Every task must specify sliceType ("AFK" or "HITL").` });
|
|
553
|
+
if (t.type === "ai" && !t.autonomy) errors.push({ taskId: t.id, field: "autonomy", message: `AI ${where} is missing autonomy. Every AI task must specify autonomy ("AFK" or "HITL").` });
|
|
554
|
+
return errors;
|
|
362
555
|
});
|
|
363
|
-
if (Array.isArray(raw.skills)) skillsAsked.set(task, raw.skills);
|
|
364
|
-
return task;
|
|
365
|
-
}
|
|
366
|
-
function verticalSliceErrors(tasks, parentTitle) {
|
|
367
|
-
const where = (t) => parentTitle ? `subtask "${t.title}" of "${parentTitle}"` : `Task "${t.title}"`;
|
|
368
|
-
const errors = [];
|
|
369
|
-
for (const t of tasks) {
|
|
370
|
-
const fail = (field, message) => errors.push({ taskId: t.id, field, message });
|
|
371
|
-
if (!t.sliceType) {
|
|
372
|
-
fail("sliceType", `${where(t)} is missing sliceType. Every task must specify sliceType ("AFK" or "HITL").`);
|
|
373
|
-
}
|
|
374
|
-
if (t.type === "ai" && !t.autonomy) {
|
|
375
|
-
fail("autonomy", `AI ${where(t)} is missing autonomy. Every AI task must specify autonomy ("AFK" or "HITL").`);
|
|
376
|
-
}
|
|
377
|
-
if (t.autonomy === "AFK" && t.userSteps && t.userSteps.length > 0) {
|
|
378
|
-
fail("userSteps", `${where(t)} has autonomy "AFK" but contains userSteps. AFK tasks must not have user touchpoints.`);
|
|
379
|
-
}
|
|
380
|
-
if (t.type === "user" && t.sliceType && t.sliceType !== "HITL") {
|
|
381
|
-
fail("sliceType", `User ${where(t)} has sliceType "${t.sliceType}". User tasks must have sliceType "HITL".`);
|
|
382
|
-
}
|
|
383
|
-
if (t.subtasks.length > 0) {
|
|
384
|
-
errors.push(...verticalSliceErrors(t.subtasks, t.title));
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return errors;
|
|
388
556
|
}
|
|
389
557
|
|
|
390
558
|
// src/services/PartialPlanParser.ts
|
|
391
559
|
function parsePartialPlan(partial) {
|
|
392
|
-
const
|
|
393
|
-
const arrayStart = findTasksArrayStart(
|
|
560
|
+
const text2 = stripModelNoise(partial);
|
|
561
|
+
const arrayStart = findTasksArrayStart(text2);
|
|
394
562
|
if (arrayStart === -1) return [];
|
|
395
563
|
const tasks = [];
|
|
396
564
|
let depth = 0;
|
|
397
565
|
let objText = "";
|
|
398
566
|
let inString = false;
|
|
399
567
|
let escaped = false;
|
|
400
|
-
for (let i = arrayStart; i <
|
|
401
|
-
const ch =
|
|
568
|
+
for (let i = arrayStart; i < text2.length; i++) {
|
|
569
|
+
const ch = text2[i];
|
|
402
570
|
if (depth > 0) objText += ch;
|
|
403
571
|
if (inString) {
|
|
404
572
|
if (escaped) escaped = false;
|
|
@@ -425,20 +593,39 @@ function parsePartialPlan(partial) {
|
|
|
425
593
|
if (depth > 0) tasks.push(rowFromObject(objText, "streaming"));
|
|
426
594
|
return tasks;
|
|
427
595
|
}
|
|
428
|
-
function findTasksArrayStart(
|
|
429
|
-
const key = /"tasks"\s*:\s*\[/.exec(
|
|
596
|
+
function findTasksArrayStart(text2) {
|
|
597
|
+
const key = /"tasks"\s*:\s*\[/.exec(text2);
|
|
430
598
|
return key ? key.index + key[0].length : -1;
|
|
431
599
|
}
|
|
432
|
-
function rowFromObject(
|
|
600
|
+
function rowFromObject(fullText, status) {
|
|
601
|
+
const objText = withoutSubtasks(fullText);
|
|
433
602
|
const model = stringField(objText, "modelLabel");
|
|
434
|
-
const
|
|
603
|
+
const readOnly = /"readOnly"\s*:\s*true/.test(objText);
|
|
435
604
|
return {
|
|
436
605
|
title: titleFromObject(objText),
|
|
437
606
|
status,
|
|
438
607
|
...model ? { model } : {},
|
|
439
|
-
...
|
|
608
|
+
...readOnly ? { readOnly: true } : {}
|
|
440
609
|
};
|
|
441
610
|
}
|
|
611
|
+
function withoutSubtasks(objText) {
|
|
612
|
+
const key = /"subtasks"\s*:\s*\[/.exec(objText);
|
|
613
|
+
if (!key) return objText;
|
|
614
|
+
let depth = 0;
|
|
615
|
+
let inString = false;
|
|
616
|
+
let escaped = false;
|
|
617
|
+
for (let i = key.index + key[0].length - 1; i < objText.length; i++) {
|
|
618
|
+
const ch = objText[i];
|
|
619
|
+
if (inString) {
|
|
620
|
+
if (escaped) escaped = false;
|
|
621
|
+
else if (ch === "\\") escaped = true;
|
|
622
|
+
else if (ch === '"') inString = false;
|
|
623
|
+
} else if (ch === '"') inString = true;
|
|
624
|
+
else if (ch === "[") depth++;
|
|
625
|
+
else if (ch === "]" && --depth === 0) return objText.slice(0, key.index) + withoutSubtasks(objText.slice(i + 1));
|
|
626
|
+
}
|
|
627
|
+
return objText.slice(0, key.index);
|
|
628
|
+
}
|
|
442
629
|
function titleFromObject(objText) {
|
|
443
630
|
const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)/.exec(objText);
|
|
444
631
|
if (!m) return "";
|