@juzser/blacksmith 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +12 -5
- package/.claude/skills/bs/dispatch.md +40 -4
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +11 -5
- package/.claude/skills/bs/wave.md +40 -3
- package/README.md +1 -1
- package/factory/orchestrator/dist/agentsSync.js +23 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/cli.js +309 -28
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +198 -31
- package/factory/orchestrator/dist/db/projector.js +51 -27
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +179 -12
- package/factory/orchestrator/dist/errorIssues.js +175 -10
- package/factory/orchestrator/dist/events.js +59 -11
- package/factory/orchestrator/dist/findings.js +25 -2
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +130 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +469 -28
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +4 -3
- package/factory/orchestrator/dist/usage.js +18 -10
- package/factory/orchestrator/dist/waivers.js +60 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/specs/roadmap.md +24 -0
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ import { latestIntegrationCheck } from './integration.js';
|
|
|
9
9
|
import { mcpBlockers } from './mcp.js';
|
|
10
10
|
import { bareTaskId, latestPlanVersion, livePlanTasks, loadPlan } from './plan.js';
|
|
11
11
|
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
12
|
-
import { latestSpecReview, specReviewBlockers } from './spec.js';
|
|
12
|
+
import { latestSpecReview, specReviewBlockers, taskSuccessors, } from './spec.js';
|
|
13
13
|
import { TERMINAL_OK_TASK_STATUSES } from './taskStatus.js';
|
|
14
14
|
import { auditWaveConcurrency, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
15
15
|
import { RESERVED_TASK_ID } from './worktree.js';
|
|
@@ -47,6 +47,90 @@ import { RESERVED_TASK_ID } from './worktree.js';
|
|
|
47
47
|
*/
|
|
48
48
|
/** The terminal-OK status that is a decision rather than a completion (D-120). */
|
|
49
49
|
const WAIVED_TASK_STATUS = 'waived';
|
|
50
|
+
/**
|
|
51
|
+
* A task a re-plan (`plan amend`) replaced with a successor task cut under a
|
|
52
|
+
* later plan version. `TERMINAL_TASK_STATUSES` (taskStatus.ts) already counts
|
|
53
|
+
* this as over; it is deliberately NOT in `TERMINAL_OK_TASK_STATUSES`, because
|
|
54
|
+
* "over" is not the same as "well" — the work only actually landed if the
|
|
55
|
+
* successor did. `resolveSupersededRow` below is what asks that question.
|
|
56
|
+
*/
|
|
57
|
+
const SUPERSEDED_TASK_STATUS = 'superseded';
|
|
58
|
+
/**
|
|
59
|
+
* Follows a superseded task to whatever eventually carries its work, walking
|
|
60
|
+
* a possibly multi-hop chain (a successor can itself be superseded by a
|
|
61
|
+
* later amendment). Returns the first row in the chain that is not itself
|
|
62
|
+
* superseded, or `undefined` when the chain is missing a hop, names a task
|
|
63
|
+
* with no fold record, or cycles back on itself — every one of those reads
|
|
64
|
+
* as "no successor to vouch for this", the fail-closed answer.
|
|
65
|
+
*
|
|
66
|
+
* `successors` is folded off the event log (spec.ts's `taskSuccessors`), so
|
|
67
|
+
* its keys and values are whatever `plan-version-created` recorded — not
|
|
68
|
+
* necessarily bare (D-46/P9-29) — hence comparing through `bareTaskId` at
|
|
69
|
+
* every hop rather than assuming the map is pre-normalized.
|
|
70
|
+
*/
|
|
71
|
+
function resolveSupersededRow(epicId, taskId, tasks, successors) {
|
|
72
|
+
const bareSuccessors = new Map([...successors].map(([from, to]) => [bareTaskId(epicId, from), to]));
|
|
73
|
+
const visited = new Set([bareTaskId(epicId, taskId)]);
|
|
74
|
+
let currentId = bareTaskId(epicId, taskId);
|
|
75
|
+
for (;;) {
|
|
76
|
+
const nextId = bareSuccessors.get(currentId);
|
|
77
|
+
if (nextId === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const nextBare = bareTaskId(epicId, nextId);
|
|
80
|
+
if (visited.has(nextBare))
|
|
81
|
+
return undefined;
|
|
82
|
+
visited.add(nextBare);
|
|
83
|
+
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === nextBare);
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (row.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
87
|
+
return row;
|
|
88
|
+
currentId = nextBare;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The `origin` taskEvents.ts's `emitFollowUpTask` stamps on a follow-up task:
|
|
93
|
+
* one minted for a finding no open task could own, rather than one a plan cut.
|
|
94
|
+
*/
|
|
95
|
+
const FOLLOW_UP_TASK_ORIGIN = 'escalation';
|
|
96
|
+
/** The finding status that is the operator's decision not to fix (D-120). */
|
|
97
|
+
const WAIVED_FINDING_STATUS = 'waived';
|
|
98
|
+
/**
|
|
99
|
+
* Whether a follow-up task's whole reason to exist has been waived away. A
|
|
100
|
+
* follow-up is minted `todo` to own findings, and no event ever moves it to
|
|
101
|
+
* `waived` — the projector has no such write — so without this the operator
|
|
102
|
+
* waiving the only finding it owns (bs-audit-2's `followup-9a8f6ac7`) left the
|
|
103
|
+
* task holding the epic verdict forever, with nothing left to do and no verb
|
|
104
|
+
* that could say so.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately narrow, the same reader-side shape as the superseded-successor
|
|
107
|
+
* rule above:
|
|
108
|
+
* - only origin `escalation`: a planned task's `todo` is a claim about work
|
|
109
|
+
* its spec still owes, which waiving a finding raised against it does not
|
|
110
|
+
* discharge;
|
|
111
|
+
* - at least one attributed finding: a follow-up that owns nothing is not
|
|
112
|
+
* "all waived", it is unexplained, and absence must not vote yes (D-126);
|
|
113
|
+
* - every attributed finding `waived`: one still open, or closed any other
|
|
114
|
+
* way (a fix verified by a gate the task never ran), still blocks.
|
|
115
|
+
*
|
|
116
|
+
* Attribution is `finding.task_id`. A finding routed to a follow-up is raised
|
|
117
|
+
* under it (attribution.ts's `reattributeFinding` re-mints the id before the
|
|
118
|
+
* raise), so `finding-reattributed` needs no second reading here. Ids compare
|
|
119
|
+
* bare (D-46/P9-29).
|
|
120
|
+
*
|
|
121
|
+
* Returns the findings that discharge the follow-up, or null on every branch
|
|
122
|
+
* the rule refuses, so the quorum prompt can NAME them instead of re-deriving
|
|
123
|
+
* the rule a second way.
|
|
124
|
+
*/
|
|
125
|
+
function clearedFollowUpFindings(epicId, row, findings) {
|
|
126
|
+
if (row.origin !== FOLLOW_UP_TASK_ORIGIN)
|
|
127
|
+
return null;
|
|
128
|
+
const bare = bareTaskId(epicId, row.taskId);
|
|
129
|
+
const owned = findings.filter((f) => bareTaskId(epicId, f.task_id) === bare);
|
|
130
|
+
return owned.length > 0 && owned.every((f) => f.finding_status === WAIVED_FINDING_STATUS)
|
|
131
|
+
? owned
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
50
134
|
const GATE_OUTCOME_EVENT = 'gate-outcome';
|
|
51
135
|
const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
52
136
|
/**
|
|
@@ -218,12 +302,43 @@ function integrationBlockers(epicId, integration) {
|
|
|
218
302
|
* dropped so it can never be read as "it ran fine". It contributes nothing to
|
|
219
303
|
* `blockers` — see {@link EpicConcurrency}.
|
|
220
304
|
*/
|
|
221
|
-
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null
|
|
305
|
+
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null,
|
|
306
|
+
/**
|
|
307
|
+
* Old task id -> the successor that superseded it (spec.ts's
|
|
308
|
+
* `taskSuccessors`, folded off every `plan-version-created` event this
|
|
309
|
+
* epic has). Defaults to empty: a caller that never threads it gets
|
|
310
|
+
* exactly today's behavior — every superseded task blocks, same as before
|
|
311
|
+
* this parameter existed.
|
|
312
|
+
*/
|
|
313
|
+
successors = new Map()) {
|
|
222
314
|
const taskSummaries = tasks.map((t) => ({
|
|
223
315
|
taskId: t.taskId,
|
|
224
316
|
taskStatus: t.taskStatus,
|
|
225
317
|
}));
|
|
226
|
-
|
|
318
|
+
// A superseded task is terminal (taskStatus.ts's TERMINAL_TASK_STATUSES)
|
|
319
|
+
// but not terminal-OK by itself — it reads terminal-OK only when the
|
|
320
|
+
// successor that replaced it does, recursively (run.md's "completed/
|
|
321
|
+
// superseded/waived" is this rule, not a second one). A follow-up task whose
|
|
322
|
+
// every finding was waived reads terminal-OK too — clearedFollowUpFindings()
|
|
323
|
+
// — and is collected below so the prompt can explain the row it clears.
|
|
324
|
+
const clearedFollowUps = [];
|
|
325
|
+
const nonTerminal = taskSummaries.filter((t, i) => {
|
|
326
|
+
if (TERMINAL_OK_TASK_STATUSES.has(t.taskStatus))
|
|
327
|
+
return false;
|
|
328
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS) {
|
|
329
|
+
const cleared = clearedFollowUpFindings(epicId, tasks[i], findings);
|
|
330
|
+
if (cleared === null)
|
|
331
|
+
return true;
|
|
332
|
+
clearedFollowUps.push({
|
|
333
|
+
taskId: t.taskId,
|
|
334
|
+
taskStatus: t.taskStatus,
|
|
335
|
+
waivedFindingIds: cleared.map((f) => f.finding_id),
|
|
336
|
+
});
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
340
|
+
return successor === undefined || !TERMINAL_OK_TASK_STATUSES.has(successor.taskStatus);
|
|
341
|
+
});
|
|
227
342
|
// D-138: only tasks claimed done are asked for evidence. One still in flight
|
|
228
343
|
// has not been gated yet and already blocks for not being terminal-OK —
|
|
229
344
|
// repeating it here would make an in-progress task read like a forged one.
|
|
@@ -304,13 +419,35 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
304
419
|
: wellFormedObligationIds.filter((id) => {
|
|
305
420
|
const bare = bareTaskId(epicId, id);
|
|
306
421
|
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
|
|
307
|
-
|
|
308
|
-
|
|
422
|
+
// An obligation named a task a LATER, separate amendment went on
|
|
423
|
+
// to supersede: the stored amends_task_ids is never rewritten
|
|
424
|
+
// (repairObligation refuses to renegotiate a well-formed
|
|
425
|
+
// obligation), so the id is resolved through its successor here,
|
|
426
|
+
// at evaluation time, instead. Only the LANDED-AT version comes
|
|
427
|
+
// from the successor row; the evidence transition() eventually
|
|
428
|
+
// re-checks (outstandingObligations, findings.ts) bare-compares
|
|
429
|
+
// AmendmentDischarge.taskId against the obligation's own
|
|
430
|
+
// amends_task_ids, so the taskId offered as proof has to stay the
|
|
431
|
+
// originally-named id (`row.taskId`) or that revalidation would
|
|
432
|
+
// never find a match and would refuse the discharge outright —
|
|
433
|
+
// the id is resolved for landing-status purposes only, never for
|
|
434
|
+
// the identity the obligation is matched by. The original row
|
|
435
|
+
// stands in unchanged (byte-identical to before this existed)
|
|
436
|
+
// whenever it was never superseded, or its chain does not
|
|
437
|
+
// resolve.
|
|
438
|
+
const evidenceRow = row !== undefined && row.taskStatus === SUPERSEDED_TASK_STATUS
|
|
439
|
+
? (resolveSupersededRow(epicId, row.taskId, tasks, successors) ?? row)
|
|
440
|
+
: row;
|
|
441
|
+
const landed = evidenceRow !== undefined &&
|
|
442
|
+
TERMINAL_OK_TASK_STATUSES.has(evidenceRow.taskStatus) &&
|
|
309
443
|
version !== undefined &&
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (landed &&
|
|
313
|
-
satisfiedBy.push({
|
|
444
|
+
evidenceRow.planVersion !== null &&
|
|
445
|
+
evidenceRow.planVersion >= version;
|
|
446
|
+
if (landed && evidenceRow.planVersion !== null && row !== undefined)
|
|
447
|
+
satisfiedBy.push({
|
|
448
|
+
taskId: row.taskId,
|
|
449
|
+
planVersion: evidenceRow.planVersion,
|
|
450
|
+
});
|
|
314
451
|
return !landed;
|
|
315
452
|
});
|
|
316
453
|
if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
|
|
@@ -348,7 +485,17 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
348
485
|
...(taskSummaries.length === 0
|
|
349
486
|
? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
|
|
350
487
|
: []),
|
|
351
|
-
...nonTerminal.map((t) =>
|
|
488
|
+
...nonTerminal.map((t) => {
|
|
489
|
+
const row = tasks.find((r) => r.taskId === t.taskId);
|
|
490
|
+
if (row?.origin === FOLLOW_UP_TASK_ORIGIN && t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
491
|
+
return `Task "${t.taskId}" is a follow-up (origin: escalation) and is not terminal-OK (status: ${t.taskStatus}). Complete it, or waive every finding attributed to it — it clears once it owns at least one finding and all of them are waived.`;
|
|
492
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
493
|
+
return `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`;
|
|
494
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
495
|
+
return successor === undefined
|
|
496
|
+
? `Task "${t.taskId}" is superseded but has no successor recorded in the log — it cannot be counted terminal-OK.`
|
|
497
|
+
: `Task "${t.taskId}" is superseded by "${successor.taskId}", which is not terminal-OK (status: ${successor.taskStatus}).`;
|
|
498
|
+
}),
|
|
352
499
|
...undispatchedTasks.map((t) => `Task "${t.taskId}" is in plan v${plan?.version} but has no events in the log — nothing records it as dispatched, let alone done (plan status: ${t.taskStatus}).`),
|
|
353
500
|
...ungated.map((t) => t.gate.gateOutcome
|
|
354
501
|
? `Task "${t.taskId}" is recorded ${t.taskStatus} and has a gate-outcome, but no task-result-recorded for it exists in the log. \`gate run\` writes both for the task it grades, so this outcome was written by hand — it is a claim, not a gate.`
|
|
@@ -378,6 +525,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
378
525
|
undispatchedTasks,
|
|
379
526
|
ungatedTasks,
|
|
380
527
|
waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
|
|
528
|
+
clearedFollowUps,
|
|
381
529
|
openFindings,
|
|
382
530
|
discretionaryFindings,
|
|
383
531
|
satisfiedAmendments,
|
|
@@ -455,8 +603,21 @@ function mcpVerdict(mcp) {
|
|
|
455
603
|
* and the exit code are the refutable part; the output is not.
|
|
456
604
|
*/
|
|
457
605
|
export function epicVerdictJudgeRequest(summary, budget) {
|
|
606
|
+
// Keyed by taskId: a row in here is one clearedFollowUpFindings() cleared,
|
|
607
|
+
// so the roster line below can say so instead of printing the raw `todo`
|
|
608
|
+
// beside `Tasks not yet terminal-OK: 0` with nothing to explain the gap —
|
|
609
|
+
// the exact contradiction both external judges refuted in bs-audit-2.
|
|
610
|
+
const clearedFollowUpsByTaskId = new Map(summary.clearedFollowUps.map((c) => [c.taskId, c]));
|
|
458
611
|
const taskLines = summary.tasks.length > 0
|
|
459
|
-
? summary.tasks
|
|
612
|
+
? summary.tasks
|
|
613
|
+
.map((t) => {
|
|
614
|
+
const cleared = clearedFollowUpsByTaskId.get(t.taskId);
|
|
615
|
+
return cleared === undefined
|
|
616
|
+
? ` ${t.taskId}: ${t.taskStatus}`
|
|
617
|
+
: ` ${t.taskId}: ${t.taskStatus} — follow-up (origin: escalation) cleared by rule: ` +
|
|
618
|
+
`every attributed finding waived (${cleared.waivedFindingIds.join(', ')}); counted terminal-OK`;
|
|
619
|
+
})
|
|
620
|
+
.join('\n')
|
|
460
621
|
: ' (no tasks)';
|
|
461
622
|
// The plan's side of the roster, stated separately: these ids have no events
|
|
462
623
|
// at all, so their status is what the plan claims, not what happened (D-126).
|
|
@@ -578,6 +739,8 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
578
739
|
'Discretionary closures — decided by a person, not shown by the machine:',
|
|
579
740
|
`Tasks waived rather than completed: ${summary.waivedTasks.length}`,
|
|
580
741
|
...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
|
|
742
|
+
`Follow-ups cleared by waiving every finding they own: ${summary.clearedFollowUps.length}`,
|
|
743
|
+
...listOr(summary.clearedFollowUps.map((c) => ` ${c.taskId} — waived findings: ${c.waivedFindingIds.join(', ')}`)),
|
|
581
744
|
`Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
|
|
582
745
|
...listOr(summary.discretionaryFindings.map(findingLine)),
|
|
583
746
|
`Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
|
|
@@ -717,7 +880,11 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
717
880
|
// Off the same lineage `events` every other fact here came from — the
|
|
718
881
|
// wave record is in that log already, so measuring width costs this call
|
|
719
882
|
// no second read and no second command anyone has to remember to type.
|
|
720
|
-
readEpicConcurrency(events, input.epicId)
|
|
883
|
+
readEpicConcurrency(events, input.epicId),
|
|
884
|
+
// Same lineage read again, no second I/O: which superseded task ids this
|
|
885
|
+
// epic's amendments paired with a replacement, so a chain of them can
|
|
886
|
+
// resolve to whatever is actually carrying the work now.
|
|
887
|
+
taskSuccessors(events, input.epicId));
|
|
721
888
|
// Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
|
|
722
889
|
// final. Zero judge calls, zero events (read-only projection).
|
|
723
890
|
if (!summary.mechanicallyReady) {
|
|
@@ -20,6 +20,10 @@ const BLOCKING_SEVERITY = 'S2-major';
|
|
|
20
20
|
const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
|
|
21
21
|
const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
|
|
22
22
|
const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
23
|
+
/** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
|
|
24
|
+
* imported -- this module's only imports are `node:crypto` and two local,
|
|
25
|
+
* type-only siblings, on purpose (see the file banner). */
|
|
26
|
+
const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
|
|
23
27
|
/**
|
|
24
28
|
* Source 2: `task_status: failed` is set in exactly one place in the whole
|
|
25
29
|
* projector, `db/projector.ts`'s `case 'task-added'` arm copying
|
|
@@ -29,8 +33,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
|
29
33
|
* carries no failure discriminator beyond that.
|
|
30
34
|
*/
|
|
31
35
|
const TASK_FAILED_ERROR_CLASS = 'task.failed';
|
|
32
|
-
/** Default project for an event stamped with none (events.ts's documented convention; the writer never sets it, only read helpers default it). */
|
|
33
|
-
const DEFAULT_PROJECT = 'black-smith';
|
|
34
36
|
function asString(value) {
|
|
35
37
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
36
38
|
}
|
|
@@ -118,20 +120,158 @@ const CANDIDATE_READERS = Object.freeze({
|
|
|
118
120
|
* would be reported outside the window the operator asked about.
|
|
119
121
|
*/
|
|
120
122
|
export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
|
|
123
|
+
/** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
|
|
124
|
+
function candidateTaskRef(record) {
|
|
125
|
+
const payload = (record.payload ?? {});
|
|
126
|
+
return asString(record.task_id) ?? asString(payload.task_ref);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Event types allowed to stamp a session's project for
|
|
130
|
+
* `sessionProjectStamps` below: the issue candidates themselves, plus
|
|
131
|
+
* `task-added` and `dispatch_decision` rows recording where a task was
|
|
132
|
+
* planned or dispatched. Deliberately excludes `issue-reported` and every
|
|
133
|
+
* other reporter-authored row -- those carry this module's own *guess* at a
|
|
134
|
+
* row's project (see `appendIssueReported` in `issueReporter.ts`), not an
|
|
135
|
+
* independent origin stamp, so folding them back in here would let one run's
|
|
136
|
+
* guess leak into the next run's resolution of an unrelated, unstamped row
|
|
137
|
+
* in the same session.
|
|
138
|
+
*/
|
|
139
|
+
const ORIGIN_STAMP_EVENT_TYPES = new Set([
|
|
140
|
+
...ISSUE_CANDIDATE_EVENT_TYPES,
|
|
141
|
+
DISPATCH_DECISION_EVENT_TYPE,
|
|
142
|
+
]);
|
|
143
|
+
/**
|
|
144
|
+
* The session's project, from its origin-stamped rows only -- and only when
|
|
145
|
+
* every one of them agrees. Real sessions mix dozens of stamped rows with
|
|
146
|
+
* unstamped ones (evidence: a foreign project's session stamps some events
|
|
147
|
+
* and not others); a unanimous stamped sibling in the same session is strong
|
|
148
|
+
* evidence for an unstamped one. A session whose origin stamps disagree
|
|
149
|
+
* tells us nothing safe to guess, so it answers unresolved rather than
|
|
150
|
+
* picking whichever stamp happened to come first.
|
|
151
|
+
*/
|
|
152
|
+
function sessionProjectStamps(events) {
|
|
153
|
+
const bySession = new Map();
|
|
154
|
+
for (const { record } of events) {
|
|
155
|
+
if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
|
|
156
|
+
continue;
|
|
157
|
+
const project = asString(record.project);
|
|
158
|
+
if (project === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const seen = bySession.get(record.session_id);
|
|
161
|
+
if (seen)
|
|
162
|
+
seen.add(project);
|
|
163
|
+
else
|
|
164
|
+
bySession.set(record.session_id, new Set([project]));
|
|
165
|
+
}
|
|
166
|
+
const stamps = new Map();
|
|
167
|
+
for (const [sessionId, projects] of bySession) {
|
|
168
|
+
if (projects.size !== 1)
|
|
169
|
+
continue;
|
|
170
|
+
const [project] = projects;
|
|
171
|
+
if (project !== undefined)
|
|
172
|
+
stamps.set(sessionId, project);
|
|
173
|
+
}
|
|
174
|
+
return stamps;
|
|
175
|
+
}
|
|
176
|
+
function taskAddedKey(sessionId, taskId) {
|
|
177
|
+
return `${sessionId}\u0000${taskId}`;
|
|
178
|
+
}
|
|
179
|
+
/** `task-added`'s own `payload.epic_id`, keyed by session and task id, so a bare ref (no epic segment) can still be widened to `<epic>/<ref>` before asking the resolver. */
|
|
180
|
+
function taskAddedEpicIds(events) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
for (const { record } of events) {
|
|
183
|
+
if (record.event_type !== TASK_ADDED_EVENT_TYPE)
|
|
184
|
+
continue;
|
|
185
|
+
const taskId = asString(record.task_id);
|
|
186
|
+
const epicId = asString(record.payload?.epic_id);
|
|
187
|
+
if (taskId !== undefined && epicId !== undefined) {
|
|
188
|
+
out.set(taskAddedKey(record.session_id, taskId), epicId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
|
|
195
|
+
* with three session-scoped fallbacks a single task ref cannot answer alone,
|
|
196
|
+
* tried strictly in this order (S2-c):
|
|
197
|
+
*
|
|
198
|
+
* 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
|
|
199
|
+
* segment) tried again as `<epic>/<ref>` using the same session's own
|
|
200
|
+
* `task-added` row when the bare ref alone cannot answer.
|
|
201
|
+
* 2. The session stamp: the ONE project every origin-stamped row in the same
|
|
202
|
+
* session agrees on (`sessionProjectStamps`), when unanimous.
|
|
203
|
+
* 3. `selfFallback`, tried last and only when neither of the above could
|
|
204
|
+
* answer -- same widening as step 1.
|
|
205
|
+
*
|
|
206
|
+
* Self-fallback sits last on purpose: it is "no plan says otherwise, and a
|
|
207
|
+
* plan exists locally for this epic, so guess this factory's own name" --
|
|
208
|
+
* the weakest of the three signals. A session's OWN unanimous stamp naming a
|
|
209
|
+
* DIFFERENT project is stronger evidence than that guess, and must not be
|
|
210
|
+
* overridden by it just because the same session also has an unrelated,
|
|
211
|
+
* unstamped row whose epic happens to have a local, unlabeled plan. Before
|
|
212
|
+
* this order existed, the self-fallback was folded into `resolveProject`
|
|
213
|
+
* itself (step 1) and so always outran the session stamp.
|
|
214
|
+
*
|
|
215
|
+
* Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
|
|
216
|
+
* fallback to the same event snapshot.
|
|
217
|
+
*/
|
|
218
|
+
export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
|
|
219
|
+
const stamps = sessionProjectStamps(events);
|
|
220
|
+
const epicIds = taskAddedEpicIds(events);
|
|
221
|
+
const tryResolve = (resolve, taskRef, sessionId) => {
|
|
222
|
+
if (taskRef === undefined)
|
|
223
|
+
return null;
|
|
224
|
+
const direct = resolve(taskRef);
|
|
225
|
+
if (direct !== null)
|
|
226
|
+
return direct;
|
|
227
|
+
if (sessionId === undefined)
|
|
228
|
+
return null;
|
|
229
|
+
const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
|
|
230
|
+
if (epicId === undefined)
|
|
231
|
+
return null;
|
|
232
|
+
return resolve(`${epicId}/${taskRef}`);
|
|
233
|
+
};
|
|
234
|
+
return (taskRef, sessionId) => {
|
|
235
|
+
const direct = tryResolve(resolveProject, taskRef, sessionId);
|
|
236
|
+
if (direct !== null)
|
|
237
|
+
return direct;
|
|
238
|
+
if (sessionId !== undefined) {
|
|
239
|
+
const stamp = stamps.get(sessionId);
|
|
240
|
+
if (stamp !== undefined)
|
|
241
|
+
return stamp;
|
|
242
|
+
}
|
|
243
|
+
return tryResolve(selfFallback, taskRef, sessionId);
|
|
244
|
+
};
|
|
245
|
+
}
|
|
121
246
|
/** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
|
|
122
|
-
function toCandidate(event) {
|
|
247
|
+
function toCandidate(event, resolveRow) {
|
|
123
248
|
const { record } = event;
|
|
124
249
|
const read = CANDIDATE_READERS[record.event_type];
|
|
125
250
|
if (!read)
|
|
126
251
|
return 'ignore';
|
|
252
|
+
const stamped = asString(record.project);
|
|
253
|
+
const taskRef = candidateTaskRef(record);
|
|
254
|
+
const sessionId = asString(record.session_id);
|
|
255
|
+
const project = stamped ?? resolveRow(taskRef, sessionId);
|
|
127
256
|
return read(event, {
|
|
128
257
|
payload: record.payload ?? {},
|
|
129
|
-
project
|
|
130
|
-
sessionId
|
|
258
|
+
project,
|
|
259
|
+
sessionId,
|
|
131
260
|
planVersion: asNumber(record.plan_version) ?? null,
|
|
132
261
|
ts: asString(record.ts),
|
|
133
262
|
});
|
|
134
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* A row whose project cannot be resolved at all still needs a stable
|
|
266
|
+
* fingerprint-grouping key -- otherwise two unrelated foreign projects'
|
|
267
|
+
* rows sharing `(source, error_class, task_ref)` would incorrectly group
|
|
268
|
+
* together and leak one row's session/epic/severity metadata onto the
|
|
269
|
+
* other's report, inside this factory's own private log (both rows are
|
|
270
|
+
* always skipped before anything is filed publicly, but the log itself
|
|
271
|
+
* should stay correct). `task_ref` alone already disambiguates in practice
|
|
272
|
+
* (refs are namespaced by epic id), so a single fixed sentinel is enough.
|
|
273
|
+
*/
|
|
274
|
+
const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
|
|
135
275
|
/**
|
|
136
276
|
* The first 16 hex characters of a SHA-256 over the NUL-joined tuple
|
|
137
277
|
* `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
|
|
@@ -140,27 +280,52 @@ function toCandidate(event) {
|
|
|
140
280
|
* and including any of them would open one issue per round instead of one.
|
|
141
281
|
*/
|
|
142
282
|
function computeFingerprint(project, source, errorClass, taskRef) {
|
|
143
|
-
const material = [
|
|
283
|
+
const material = [
|
|
284
|
+
project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
|
|
285
|
+
source,
|
|
286
|
+
errorClass,
|
|
287
|
+
taskRef,
|
|
288
|
+
].join('\0');
|
|
144
289
|
return createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
145
290
|
}
|
|
146
291
|
/**
|
|
147
292
|
* Fold a stored event sequence into reportable errors. Pure: two calls with
|
|
148
293
|
* identical input return byte-identical output. `now` is threaded in rather
|
|
149
294
|
* than read off the clock; nothing in this contract branches on it yet.
|
|
295
|
+
*
|
|
296
|
+
* `resolveProject` and `selfFallback` both default to "no answer" so the
|
|
297
|
+
* ~50 existing call sites across this module's own tests and
|
|
298
|
+
* `issueReporter.ts`'s need no change. An unstamped row still unresolved
|
|
299
|
+
* after `withSessionFallback`'s three fallbacks reports `project: null`
|
|
300
|
+
* rather than this factory's own name -- `isProjectEnabled` is never asked
|
|
301
|
+
* about a row it cannot identify, and the decision to skip filing it
|
|
302
|
+
* belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
|
|
303
|
+
*
|
|
304
|
+
* `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
|
|
305
|
+
* maps `withSessionFallback` builds read the same rows this fold iterates,
|
|
306
|
+
* same as before this parameter existed. A caller that narrows `events` to a
|
|
307
|
+
* candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
|
|
308
|
+
* lineage here instead (S3), so a session-mate's stamp sitting outside that
|
|
309
|
+
* window still counts as evidence for a row inside it -- scoping which rows
|
|
310
|
+
* get REPORTED must not also scope which rows get CONSULTED, or the same
|
|
311
|
+
* unstamped row resolves to a different project (and hashes a different
|
|
312
|
+
* fingerprint, S1-b) depending on whether the caller happened to pass
|
|
313
|
+
* `--epic`.
|
|
150
314
|
*/
|
|
151
|
-
export function foldErrorEvents(events, now, isProjectEnabled) {
|
|
315
|
+
export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
152
316
|
void now;
|
|
317
|
+
const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
|
|
153
318
|
const candidates = [];
|
|
154
319
|
let skipped = 0;
|
|
155
320
|
for (const event of events) {
|
|
156
|
-
const outcome = toCandidate(event);
|
|
321
|
+
const outcome = toCandidate(event, resolveRow);
|
|
157
322
|
if (outcome === 'ignore')
|
|
158
323
|
continue;
|
|
159
324
|
if (outcome === null) {
|
|
160
325
|
skipped += 1;
|
|
161
326
|
continue;
|
|
162
327
|
}
|
|
163
|
-
if (!isProjectEnabled(outcome.project))
|
|
328
|
+
if (outcome.project !== null && !isProjectEnabled(outcome.project))
|
|
164
329
|
continue;
|
|
165
330
|
candidates.push(outcome);
|
|
166
331
|
}
|
|
@@ -245,7 +410,7 @@ export function renderBody(fields) {
|
|
|
245
410
|
`Session: ${fields.session_id}`,
|
|
246
411
|
`Epic: ${fields.epic_id ?? '(none)'}`,
|
|
247
412
|
`Plan version: ${fields.plan_version ?? '(unknown)'}`,
|
|
248
|
-
`Project: ${fields.project}`,
|
|
413
|
+
`Project: ${fields.project ?? '(unknown)'}`,
|
|
249
414
|
`Source: ${fields.source}`,
|
|
250
415
|
fingerprintLine(fields.fingerprint),
|
|
251
416
|
'',
|
|
@@ -471,8 +471,28 @@ function validateResultArtifactsShape(record) {
|
|
|
471
471
|
errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
|
|
472
472
|
});
|
|
473
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* `error-logged`'s task_id (D-245: envelope or payload, envelope wins) names
|
|
476
|
+
* exactly one task, the same rule taxonomy.ts enforces for `task_ref` — but
|
|
477
|
+
* `task_id` is schema-typed as a plain string, so no taxonomy rule ever sees
|
|
478
|
+
* it, and a comma-joined "epic-1/task-1,epic-1/task-2" passed straight
|
|
479
|
+
* through. db/projector.ts's fold read that string as a single task id and
|
|
480
|
+
* minted a phantom row for it that stayed `todo` forever. Checked here,
|
|
481
|
+
* beside validateResultArtifactsShape, for the same reason: the shape a
|
|
482
|
+
* later reader assumes is not one the event schema or taxonomy already rule
|
|
483
|
+
* out.
|
|
484
|
+
*/
|
|
485
|
+
function validateErrorLoggedTaskIdShape(record) {
|
|
486
|
+
if (record.event_type !== 'error-logged')
|
|
487
|
+
return;
|
|
488
|
+
const taskId = eventTaskId(record);
|
|
489
|
+
if (taskId === null || !taskId.includes(','))
|
|
490
|
+
return;
|
|
491
|
+
throw new EventError('events.multi-value-task-id', `Event "error-logged" names more than one task in its task_id ("${taskId}"). Log one error-logged event per task instead.`, { event_type: record.event_type, task_id: taskId });
|
|
492
|
+
}
|
|
474
493
|
function validateTypedPayload(schemas, taxonomy, record) {
|
|
475
494
|
validateResultArtifactsShape(record);
|
|
495
|
+
validateErrorLoggedTaskIdShape(record);
|
|
476
496
|
const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
|
|
477
497
|
if (schemaName === undefined)
|
|
478
498
|
return;
|
|
@@ -524,19 +544,47 @@ async function validateCausalParent(input, opts, existing) {
|
|
|
524
544
|
throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
|
|
525
545
|
}
|
|
526
546
|
}
|
|
547
|
+
// EventInput requires causal_parent, so inside the orchestrator undefined is
|
|
548
|
+
// unreachable. It is reachable from outside: every event appended through the
|
|
549
|
+
// CLI crosses a JSON boundary where the type guarantees nothing, and omitting
|
|
550
|
+
// a field is the likeliest way a hand-written event is malformed. This used
|
|
551
|
+
// to compare against `null` alone, so an omitted parent fell through into
|
|
552
|
+
// parseEventId(undefined) and reached the caller as a raw TypeError with no
|
|
553
|
+
// `code` — the one shape produced by accident was the one shape with nothing
|
|
554
|
+
// to branch on. Normalise once so every caller reads the same guard.
|
|
555
|
+
function requireCausalParentPresence(eventType, causalParent) {
|
|
556
|
+
if (causalParent === null && eventType !== ROOT_EVENT_TYPE) {
|
|
557
|
+
throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${eventType}").`, { event_type: eventType });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Validate an event's envelope — the same causal_parent checks `appendEvent`
|
|
562
|
+
* runs immediately before it writes — without appending anything.
|
|
563
|
+
*
|
|
564
|
+
* `queue.ts`'s `step`/`batchStep` and `integration.ts`'s check suite each do
|
|
565
|
+
* real work of their own (a rebase and a no-ff merge; a full check run) ahead
|
|
566
|
+
* of their own first append, so a bad `--causal-parent` used to surface only
|
|
567
|
+
* there — after the git work had already landed for real, with no event to
|
|
568
|
+
* show for it. A retry then found the branch already advanced and reported it
|
|
569
|
+
* as stuck. Calling this first, before any of that work starts, means a bad
|
|
570
|
+
* envelope is refused with nothing done yet to undo.
|
|
571
|
+
*
|
|
572
|
+
* Only the causal_parent rules and the session-id shape are checked here. The
|
|
573
|
+
* event schema, the taxonomy and typed payloads are not: a caller whose payload
|
|
574
|
+
* could fail those must not treat a pass as "the append will succeed".
|
|
575
|
+
*/
|
|
576
|
+
export async function validateEventEnvelope(input, opts = {}) {
|
|
577
|
+
const causalParent = input.causal_parent ?? null;
|
|
578
|
+
requireCausalParentPresence(input.event_type, causalParent);
|
|
579
|
+
if (causalParent === null)
|
|
580
|
+
return;
|
|
581
|
+
const filePath = logPath(input.session_id, opts);
|
|
582
|
+
const existing = await readEventsAtPath(filePath, input.session_id);
|
|
583
|
+
await validateCausalParent(input, opts, existing);
|
|
584
|
+
}
|
|
527
585
|
async function appendEventLocked(input, opts, filePath) {
|
|
528
|
-
// EventInput requires causal_parent, so inside the orchestrator undefined is
|
|
529
|
-
// unreachable. It is reachable from outside: every event appended through the
|
|
530
|
-
// CLI crosses a JSON boundary where the type guarantees nothing, and omitting
|
|
531
|
-
// a field is the likeliest way a hand-written event is malformed. Both guards
|
|
532
|
-
// below used to compare against `null` alone, so an omitted parent fell
|
|
533
|
-
// through them into parseEventId(undefined) and reached the caller as a raw
|
|
534
|
-
// TypeError with no `code` — the one shape produced by accident was the one
|
|
535
|
-
// shape with nothing to branch on. Normalise once so both guards read it.
|
|
536
586
|
const causalParent = input.causal_parent ?? null;
|
|
537
|
-
|
|
538
|
-
throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
|
|
539
|
-
}
|
|
587
|
+
requireCausalParentPresence(input.event_type, causalParent);
|
|
540
588
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
541
589
|
const existing = await readEventsAtPath(filePath, input.session_id);
|
|
542
590
|
if (causalParent !== null) {
|
|
@@ -250,7 +250,9 @@ function escapeRegExp(text) {
|
|
|
250
250
|
* forms) — never a bare `:digits` anywhere in the text. A blanket
|
|
251
251
|
* `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
|
|
252
252
|
* would collide with an unrelated ":9090" finding (reproduced regression,
|
|
253
|
-
* reviewer finding #3).
|
|
253
|
+
* reviewer finding #3). The line number may itself be a range
|
|
254
|
+
* (`Widget.tsx:1549-1576`), which a re-review reports as often as a single
|
|
255
|
+
* line (issue #178).
|
|
254
256
|
*/
|
|
255
257
|
function stripPathLineRef(summary, filePath) {
|
|
256
258
|
const forwardSlashPath = filePath.replace(/\\/g, '/');
|
|
@@ -259,16 +261,37 @@ function stripPathLineRef(summary, filePath) {
|
|
|
259
261
|
for (const candidate of candidates) {
|
|
260
262
|
if (!candidate)
|
|
261
263
|
continue;
|
|
262
|
-
const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
|
|
264
|
+
const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?:[-–—]\\d+)?(?::\\d+)?`, 'g');
|
|
263
265
|
result = result.replace(pattern, candidate);
|
|
264
266
|
}
|
|
265
267
|
return result;
|
|
266
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* A `lines N-M` / `lines N–M` (en dash) / `line N to line M` range — the
|
|
271
|
+
* shape a judge describing a large component actually writes, and the one
|
|
272
|
+
* the original single `line N` strip below never covered (issue #178).
|
|
273
|
+
* Anchored on the word "line"/"lines" immediately before the numbers, so
|
|
274
|
+
* free-text numerics elsewhere in the sentence are never touched.
|
|
275
|
+
*/
|
|
276
|
+
const LINE_SPAN_RE = /\blines?\s+\d[\d,]*\s*(?:[-–—]|to(?:\s+lines?)?)\s*\d[\d,]*\b/g;
|
|
277
|
+
/**
|
|
278
|
+
* `~5,050-line` / `6,720-line` — a size given as a hyphenated adjective
|
|
279
|
+
* before "line"/"component"/"file"/etc, approximate or exact. Only the
|
|
280
|
+
* `<number>-line` token is removed; the noun it modifies is untouched, and
|
|
281
|
+
* a hyphenated count before any OTHER noun (`3-day`, `5-retry`) is left
|
|
282
|
+
* alone — it is not a line reference.
|
|
283
|
+
*/
|
|
284
|
+
const LINE_COUNT_RE = /~?\d[\d,]*-line\b/g;
|
|
285
|
+
/** `L1549-L6600` — a GitHub-style permalink line range, bare or after `#`. */
|
|
286
|
+
const GITHUB_LINE_RANGE_RE = /\bl\d+-l\d+\b/g;
|
|
267
287
|
/** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
|
|
268
288
|
function normalizeSummary(summary) {
|
|
269
289
|
return summary
|
|
270
290
|
.toLowerCase()
|
|
291
|
+
.replace(LINE_SPAN_RE, '')
|
|
271
292
|
.replace(/\bline\s+\d+\b/g, '')
|
|
293
|
+
.replace(LINE_COUNT_RE, '')
|
|
294
|
+
.replace(GITHUB_LINE_RANGE_RE, '')
|
|
272
295
|
.replace(/\s+/g, ' ')
|
|
273
296
|
.trim();
|
|
274
297
|
}
|