@cleocode/contracts 2026.8.9 → 2026.9.2
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/acceptance-gate.d.ts +18 -1
- package/dist/acceptance-gate.d.ts.map +1 -1
- package/dist/branch-lock.d.ts +24 -0
- package/dist/branch-lock.d.ts.map +1 -1
- package/dist/branch-lock.js.map +1 -1
- package/dist/conduit.d.ts +36 -3
- package/dist/conduit.d.ts.map +1 -1
- package/dist/data-accessor.d.ts +22 -1
- package/dist/data-accessor.d.ts.map +1 -1
- package/dist/data-accessor.js.map +1 -1
- package/dist/dispatch/operations-registry.d.ts.map +1 -1
- package/dist/dispatch/operations-registry.js +14 -2
- package/dist/dispatch/operations-registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/operations/output-contracts-data.d.ts.map +1 -1
- package/dist/operations/output-contracts-data.js +18 -3
- package/dist/operations/output-contracts-data.js.map +1 -1
- package/dist/operations/session.d.ts +13 -1
- package/dist/operations/session.d.ts.map +1 -1
- package/dist/operations/tasks.d.ts +39 -1
- package/dist/operations/tasks.d.ts.map +1 -1
- package/dist/operations/tasks.js.map +1 -1
- package/dist/task-id.d.ts +86 -0
- package/dist/task-id.d.ts.map +1 -0
- package/dist/task-id.js +118 -0
- package/dist/task-id.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
- package/src/acceptance-gate.ts +18 -1
- package/src/branch-lock.ts +24 -0
- package/src/conduit.ts +36 -3
- package/src/data-accessor.ts +30 -1
- package/src/dispatch/operations-registry.ts +17 -2
- package/src/index.ts +2 -0
- package/src/operations/output-contracts-data.ts +18 -3
- package/src/operations/session.ts +13 -1
- package/src/operations/tasks.ts +39 -1
- package/src/task-id.ts +127 -0
package/src/acceptance-gate.ts
CHANGED
|
@@ -335,7 +335,24 @@ export interface AcceptanceGateResult {
|
|
|
335
335
|
req?: string;
|
|
336
336
|
/** Gate variant. */
|
|
337
337
|
kind: AcceptanceGateKind;
|
|
338
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Outcome of the gate execution.
|
|
340
|
+
*
|
|
341
|
+
* `fail` and `error` are NOT interchangeable, and the difference is the whole
|
|
342
|
+
* point of having both (gh#1270):
|
|
343
|
+
*
|
|
344
|
+
* - `pass` — ran to completion, satisfied.
|
|
345
|
+
* - `fail` — ran to completion, **found a problem**. A verdict.
|
|
346
|
+
* - `warn` — a `fail` on a gate marked `advisory`.
|
|
347
|
+
* - `skipped` — deliberately not run.
|
|
348
|
+
* - `error` — **did not finish**: killed by timeout or signal, or could not
|
|
349
|
+
* be executed. This is *not a verdict*. Recording it as `fail` produces a
|
|
350
|
+
* false red, which costs an agent work it had actually completed and writes
|
|
351
|
+
* an assertion into the evidence record that a check found a problem when
|
|
352
|
+
* none was ever made. Consumers must treat `error` as "unknown, re-run",
|
|
353
|
+
* never as "failed" — see the mapping in `lifecycle/index.ts`, which
|
|
354
|
+
* resolves it to `pending`.
|
|
355
|
+
*/
|
|
339
356
|
result: 'pass' | 'fail' | 'warn' | 'skipped' | 'error';
|
|
340
357
|
/** Wall-clock duration of the gate execution in milliseconds. */
|
|
341
358
|
durationMs: number;
|
package/src/branch-lock.ts
CHANGED
|
@@ -89,6 +89,30 @@ export interface WorktreeMergeResult {
|
|
|
89
89
|
branchDeleted: boolean;
|
|
90
90
|
/** Error message if any step failed (non-fatal — caller decides). */
|
|
91
91
|
error?: string;
|
|
92
|
+
/**
|
|
93
|
+
* True when there was **nothing to integrate** — no `task/<taskId>` branch and
|
|
94
|
+
* no worktree directory ever existed for this task.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* T12153 (GH #1223): this is NOT a failure, and conflating it with one made
|
|
98
|
+
* `cleo complete` log
|
|
99
|
+
*
|
|
100
|
+
* ```
|
|
101
|
+
* WARN … mergeError: "task branch 'task/T1655' does not exist"
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* for every task worked on a feature branch and merged by PR — which is most
|
|
105
|
+
* of them. `merged: false` plus an `error` was the only shape available, so a
|
|
106
|
+
* routine, correct outcome was reported at WARN as an integration failure.
|
|
107
|
+
*
|
|
108
|
+
* Noise at WARN is worse than silence: it trains a reader to ignore a channel
|
|
109
|
+
* that should mean something, so the one genuine rebase conflict is filtered
|
|
110
|
+
* out along with the hundred non-events.
|
|
111
|
+
*
|
|
112
|
+
* Callers should treat `nothingToIntegrate: true` as success-with-no-work,
|
|
113
|
+
* and reserve failure handling for `merged: false` WITHOUT this flag.
|
|
114
|
+
*/
|
|
115
|
+
nothingToIntegrate?: boolean;
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
/**
|
package/src/conduit.ts
CHANGED
|
@@ -119,12 +119,45 @@ export interface ConduitSendOptions {
|
|
|
119
119
|
metadata?: Record<string, unknown>;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Result of sending a message.
|
|
124
|
+
*
|
|
125
|
+
* ## Why `deliveredAt` is nullable (gh#1316)
|
|
126
|
+
*
|
|
127
|
+
* It was `deliveredAt: string`, and `ConduitClient.send` filled it with
|
|
128
|
+
* `new Date().toISOString()` for a row the transport had just written with
|
|
129
|
+
* `status = 'pending'`. That is not an approximation of a delivery time — the
|
|
130
|
+
* transport returns none, the row says the message is undelivered, and the
|
|
131
|
+
* schema has a SEPARATE real `delivered_at` that a different code path fills in
|
|
132
|
+
* later. The system models the distinction correctly everywhere except at the
|
|
133
|
+
* boundary where it is reported.
|
|
134
|
+
*
|
|
135
|
+
* Because the field was REQUIRED, "accepted but not yet delivered" — the normal
|
|
136
|
+
* case, and the state every message passes through — was **unrepresentable at
|
|
137
|
+
* the type level**. So the only way to satisfy the contract was to invent a
|
|
138
|
+
* value, which is what the code did.
|
|
139
|
+
*
|
|
140
|
+
* The two facts are now separate: `acceptedAt` is what a send genuinely knows,
|
|
141
|
+
* and `deliveredAt` is `null` until delivery actually happens.
|
|
142
|
+
*/
|
|
123
143
|
export interface ConduitSendResult {
|
|
124
144
|
/** The assigned message ID. */
|
|
125
145
|
messageId: string;
|
|
126
|
-
/**
|
|
127
|
-
|
|
146
|
+
/**
|
|
147
|
+
* ISO 8601 timestamp at which the transport ACCEPTED the message.
|
|
148
|
+
*
|
|
149
|
+
* Always known at send time. This is what the old `deliveredAt` actually
|
|
150
|
+
* contained.
|
|
151
|
+
*/
|
|
152
|
+
acceptedAt: string;
|
|
153
|
+
/**
|
|
154
|
+
* ISO 8601 timestamp of actual delivery, or `null` when not yet delivered.
|
|
155
|
+
*
|
|
156
|
+
* `null` is the normal result of a `send`: the row is written `pending` and a
|
|
157
|
+
* later path records real delivery. A non-null value here means delivery is
|
|
158
|
+
* confirmed — never that it is assumed.
|
|
159
|
+
*/
|
|
160
|
+
deliveredAt: string | null;
|
|
128
161
|
}
|
|
129
162
|
|
|
130
163
|
/** Unsubscribe function returned by event subscriptions. */
|
package/src/data-accessor.ts
CHANGED
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
|
|
16
16
|
import type { ArchivedTask } from './archive.js';
|
|
17
17
|
import type { Session } from './session.js';
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
Task,
|
|
20
|
+
TaskKind,
|
|
21
|
+
TaskPriority,
|
|
22
|
+
TaskSeverity,
|
|
23
|
+
TaskSize,
|
|
24
|
+
TaskStatus,
|
|
25
|
+
TaskType,
|
|
26
|
+
} from './task.js';
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Agent instance row shape for DataAccessor methods.
|
|
@@ -59,6 +67,27 @@ export interface TaskQueryFilters {
|
|
|
59
67
|
status?: TaskStatus | TaskStatus[];
|
|
60
68
|
priority?: TaskPriority;
|
|
61
69
|
type?: TaskType;
|
|
70
|
+
/**
|
|
71
|
+
* Severity axis filter (`P0`-`P3`). Orthogonal to {@link priority}.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* T12120 (GH #1245): `severity` is a first-class ADR-066 axis that
|
|
75
|
+
* `cleo add --severity` persists, but it had no read path at any layer —
|
|
76
|
+
* so `cleo list --severity P0` returned every task with no signal that the
|
|
77
|
+
* constraint had been dropped. A filter that is accepted and not applied
|
|
78
|
+
* fails OPEN, which is the most dangerous possible answer to "show me only
|
|
79
|
+
* the critical items".
|
|
80
|
+
*/
|
|
81
|
+
severity?: TaskSeverity | TaskSeverity[];
|
|
82
|
+
/**
|
|
83
|
+
* Kind axis filter (`work`/`research`/`experiment`/`bug`/`spike`/`release`).
|
|
84
|
+
* Orthogonal to {@link type}. Stored in the `role` DB column.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* T12120 (GH #1245): missing from the read path for the same reason as
|
|
88
|
+
* {@link severity}.
|
|
89
|
+
*/
|
|
90
|
+
kind?: TaskKind | TaskKind[];
|
|
62
91
|
parentId?: string | null; // null = root tasks only
|
|
63
92
|
phase?: string;
|
|
64
93
|
label?: string;
|
|
@@ -292,19 +292,34 @@ export const OPERATIONS: OperationDef[] = [
|
|
|
292
292
|
{ name: 'status', type: 'string', required: false, description: 'Filter by task status' },
|
|
293
293
|
{ name: 'priority', type: 'string', required: false, description: 'Filter by task priority' },
|
|
294
294
|
{ name: 'type', type: 'string', required: false, description: 'Filter by task type' },
|
|
295
|
+
{
|
|
296
|
+
name: 'severity',
|
|
297
|
+
type: 'string',
|
|
298
|
+
required: false,
|
|
299
|
+
description: 'Filter by severity axis (P0|P1|P2|P3); invalid values are rejected',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'kind',
|
|
303
|
+
type: 'string',
|
|
304
|
+
required: false,
|
|
305
|
+
description:
|
|
306
|
+
'Filter by kind axis (work|research|experiment|bug|spike|release); invalid values are rejected',
|
|
307
|
+
},
|
|
295
308
|
{ name: 'phase', type: 'string', required: false, description: 'Filter by task phase' },
|
|
296
309
|
{ name: 'label', type: 'string', required: false, description: 'Filter by task label' },
|
|
297
310
|
{
|
|
298
311
|
name: 'children',
|
|
299
312
|
type: 'boolean',
|
|
300
313
|
required: false,
|
|
301
|
-
description:
|
|
314
|
+
description:
|
|
315
|
+
'DEPRECATED (no effect; slated for removal in a future major): --parent already returns DIRECT children only, for every parent type including sagas. Do not use.',
|
|
302
316
|
},
|
|
303
317
|
{
|
|
304
318
|
name: 'limit',
|
|
305
319
|
type: 'number',
|
|
306
320
|
required: false,
|
|
307
|
-
description:
|
|
321
|
+
description:
|
|
322
|
+
'Maximum number of tasks to return (default 10). Use --all to return every match. (--limit 0 also returns every match, but only on this command — prefer --all.)',
|
|
308
323
|
},
|
|
309
324
|
{
|
|
310
325
|
name: 'offset',
|
package/src/index.ts
CHANGED
|
@@ -2669,3 +2669,5 @@ export {
|
|
|
2669
2669
|
} from './caamp-markers.js';
|
|
2670
2670
|
// T12077: SSoT for the keys under which payloads carry their rows.
|
|
2671
2671
|
export { COLLECTION_KEYS, type CollectionKey } from './collection-keys.js';
|
|
2672
|
+
|
|
2673
|
+
export { isStorableTaskId, isTaskId, TASK_ID_REGEX, type TaskId } from './task-id.js';
|
|
@@ -66,10 +66,15 @@ const tasksShowOutputContract: OperationOutputContract = {
|
|
|
66
66
|
operation: 'tasks.show',
|
|
67
67
|
shapeNote:
|
|
68
68
|
'The task record is nested under `task` — use /data/task/<field>, not /data/<field>. ' +
|
|
69
|
+
'Mutation envelopes are FLAT by contrast (/data/created/0, /data/updated/0), which is ' +
|
|
70
|
+
'the asymmetry that produced GH #1225/#1239/#1231. ' +
|
|
69
71
|
'`view` may be null. `acRows` and `relations` are conditional. ' +
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'
|
|
72
|
+
'`description`, `acceptance` and `verification` are withheld by the default (MVI) ' +
|
|
73
|
+
'projection but `--field` resolves them transparently (T12108) — no `--full` needed. ' +
|
|
74
|
+
'Evidence is NOT a task field: it lives at /data/task/verification/evidence ' +
|
|
75
|
+
'(keyed by gate), alongside /data/task/verification/gates. T12127 (GH #1231) — the ' +
|
|
76
|
+
'previous wording advertised `/data/task/evidence`, which does not exist, in the very ' +
|
|
77
|
+
'text shown to an agent whose pointer had just failed.',
|
|
73
78
|
dataSchema: {
|
|
74
79
|
type: 'object',
|
|
75
80
|
required: ['task', 'view', 'attachments'],
|
|
@@ -114,6 +119,16 @@ const tasksShowOutputContract: OperationOutputContract = {
|
|
|
114
119
|
'/data/task/priority',
|
|
115
120
|
'/data/task/type',
|
|
116
121
|
'/data/task/parentId',
|
|
122
|
+
// T12127 (GH #1231) — withheld by the MVI projection but resolvable
|
|
123
|
+
// through `--field` since T12108. They were absent from this list, so the
|
|
124
|
+
// remediation an agent is shown on a failed pointer never mentioned the
|
|
125
|
+
// fields it most often wanted, and `cleo show --field /data/task` came
|
|
126
|
+
// back looking like a write that had silently done nothing.
|
|
127
|
+
'/data/task/description',
|
|
128
|
+
'/data/task/acceptance',
|
|
129
|
+
'/data/task/verification',
|
|
130
|
+
'/data/task/verification/gates',
|
|
131
|
+
'/data/task/verification/evidence',
|
|
117
132
|
'/data/view/id',
|
|
118
133
|
'/data/view/title',
|
|
119
134
|
],
|
|
@@ -242,6 +242,17 @@ export interface BriefingFieldRule {
|
|
|
242
242
|
* Violations are emitted when an item carries one of these provenance values.
|
|
243
243
|
*/
|
|
244
244
|
excludeProvenance?: BriefingExcludeProvenance[];
|
|
245
|
+
/**
|
|
246
|
+
* Keys that MUST be present on this field's object, even when the value is
|
|
247
|
+
* `null`. Applies to object-valued fields (not lists) and exists to stop an
|
|
248
|
+
* absent key from being indistinguishable from a recorded-empty one.
|
|
249
|
+
*
|
|
250
|
+
* The motivating case (GH #1277) is `lastSession.handoff.note`: a handoff
|
|
251
|
+
* that recorded no note omitted the key entirely, so a lost handoff and a
|
|
252
|
+
* note-free handoff were byte-identical to a consumer. `null` satisfies the
|
|
253
|
+
* rule; a missing key does not.
|
|
254
|
+
*/
|
|
255
|
+
requireKeys?: readonly string[];
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
/**
|
|
@@ -284,8 +295,9 @@ export interface ContractViolation {
|
|
|
284
295
|
* - `stale` — field data exceeds `maxAgeDays`.
|
|
285
296
|
* - `duplicate` — two items share the same `dedupBy` key value.
|
|
286
297
|
* - `excluded-provenance` — item carries a banned provenance tag.
|
|
298
|
+
* - `missing-key` — an object field omits a key named in `requireKeys`.
|
|
287
299
|
*/
|
|
288
|
-
kind: 'stale' | 'duplicate' | 'excluded-provenance';
|
|
300
|
+
kind: 'stale' | 'duplicate' | 'excluded-provenance' | 'missing-key';
|
|
289
301
|
/** Severity — P0 violations block `cleo briefing --strict`. */
|
|
290
302
|
severity: 'P0' | 'P1';
|
|
291
303
|
}
|
package/src/operations/tasks.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { TaskAnalysisResult, TaskRef } from '../results.js';
|
|
|
24
24
|
* Common task types (API contract — matches CLI src/types/task.ts)
|
|
25
25
|
*/
|
|
26
26
|
import type { TaskStatus } from '../status-registry.js';
|
|
27
|
-
import type { TaskPriority, TaskType } from '../task.js';
|
|
27
|
+
import type { TaskKind, TaskPriority, TaskSeverity, TaskType } from '../task.js';
|
|
28
28
|
import type { TaskRecord } from '../task-record.js';
|
|
29
29
|
import type { ExternalTask, ExternalTaskLink, ReconcileResult } from '../task-sync.js';
|
|
30
30
|
import type {
|
|
@@ -83,8 +83,34 @@ export interface TasksListParams {
|
|
|
83
83
|
status?: TaskStatus;
|
|
84
84
|
priority?: TaskPriority;
|
|
85
85
|
type?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Filter by severity axis (`P0`-`P3`), orthogonal to {@link priority}.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* T12120 (GH #1245): previously absent from every layer of the read path,
|
|
91
|
+
* so `cleo list --severity P0` returned every task with no signal that the
|
|
92
|
+
* constraint had been dropped. Values outside `TASK_SEVERITIES` are rejected
|
|
93
|
+
* with `E_VALIDATION` — an unrecognised filter value must never widen a
|
|
94
|
+
* result set.
|
|
95
|
+
*/
|
|
96
|
+
severity?: TaskSeverity | TaskSeverity[];
|
|
97
|
+
/**
|
|
98
|
+
* Filter by kind axis (ADR-066), orthogonal to {@link type}.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* T12120 (GH #1246): absent from the read path for the same reason as
|
|
102
|
+
* {@link severity}. Stored in the `role` DB column.
|
|
103
|
+
*/
|
|
104
|
+
kind?: TaskKind | TaskKind[];
|
|
86
105
|
phase?: string;
|
|
87
106
|
label?: string;
|
|
107
|
+
/**
|
|
108
|
+
* No-op, retained for compatibility.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* T12120 (GH #1247): `--parent` already restricts to DIRECT children on
|
|
112
|
+
* every path, so there is no transitive mode for this flag to narrow from.
|
|
113
|
+
*/
|
|
88
114
|
children?: boolean;
|
|
89
115
|
limit?: number;
|
|
90
116
|
offset?: number;
|
|
@@ -1401,6 +1427,18 @@ export interface TasksAddParams {
|
|
|
1401
1427
|
description?: string;
|
|
1402
1428
|
/** Canonical wire field for parent task ID. @see ADR-057 D2 */
|
|
1403
1429
|
parent?: string;
|
|
1430
|
+
/**
|
|
1431
|
+
* Where {@link parent} came from, when the caller knows.
|
|
1432
|
+
*
|
|
1433
|
+
* @remarks
|
|
1434
|
+
* T12136 (GH #1232/#1238): the CLI infers a parent from the session's
|
|
1435
|
+
* `current` pointer before dispatch, so core cannot tell an inherited parent
|
|
1436
|
+
* from a named one. Without that distinction
|
|
1437
|
+
* `E_CLEO_DEPTH_EXCEEDED` names a task the caller never mentioned and
|
|
1438
|
+
* suggests reparenting into a hierarchy it never intended to touch.
|
|
1439
|
+
* Defaults to `'explicit'`.
|
|
1440
|
+
*/
|
|
1441
|
+
parentSource?: 'explicit' | 'session-inference';
|
|
1404
1442
|
depends?: string[];
|
|
1405
1443
|
priority?: string;
|
|
1406
1444
|
labels?: string[];
|
package/src/task-id.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical shape of a task identifier (T12128).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* The repo carried at least five different opinions about what a task ID looks
|
|
7
|
+
* like, and they disagreed with each other:
|
|
8
|
+
*
|
|
9
|
+
* | pattern | where | disagrees about |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `/^T[0-9]{1,7}$/` | `goal.ts`, `evidence-atom-schema.ts` (8 sites) | upper bound |
|
|
12
|
+
* | `/^T(\d{3,})$/` | `tasks/id-generator.ts` | rejects `T12`, allows `T123456789` |
|
|
13
|
+
* | `/^T\d{3,}$/` | `sentient/tick.ts`, `nexus/query.ts` | same |
|
|
14
|
+
* | `/\bT(\d{4,})\b/` | `lifecycle/consolidate-rcasd.ts` | unanchored, 4-digit minimum |
|
|
15
|
+
* | `/^(T\d+(-[A-Z]\w*)?\|E-\d+...)$/` | `changesets.ts` | allows suffixes and `E-` |
|
|
16
|
+
*
|
|
17
|
+
* Disagreement between validators is survivable. What was not survivable is
|
|
18
|
+
* that **none of them ran on the write path**. A project path was written into
|
|
19
|
+
* the `id` column of `tasks_tasks` — `id='/mnt/projects/cleocode'`,
|
|
20
|
+
* `title='Task /mnt/projects/cleocode'`, `type=null` — and the row is now
|
|
21
|
+
* **immortal**: `cleo list` returns it, but `cleo show`, `cleo update` and
|
|
22
|
+
* `cleo delete` all reject the id as malformed before they can reach it. The
|
|
23
|
+
* read-side validators that would have prevented it are the ones that make it
|
|
24
|
+
* unfixable.
|
|
25
|
+
*
|
|
26
|
+
* {@link TASK_ID_REGEX} deliberately adopts the `{1,7}` shape already used at
|
|
27
|
+
* eight sites rather than inventing a sixth opinion. It is permissive by
|
|
28
|
+
* design: the job here is to reject values that are not task IDs at all, not to
|
|
29
|
+
* re-litigate how many digits a task ID should have. Narrowing it later is a
|
|
30
|
+
* separate decision with its own migration.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
* @task T12128
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canonical task-identifier pattern: `T` followed by 1-7 digits.
|
|
38
|
+
*
|
|
39
|
+
* Anchored at both ends — an unanchored variant would accept `see T1234 for
|
|
40
|
+
* details` as an id, which is how prose ends up in an id column.
|
|
41
|
+
*/
|
|
42
|
+
export const TASK_ID_REGEX = /^T[0-9]{1,7}$/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A string known to be a well-formed task identifier.
|
|
46
|
+
*
|
|
47
|
+
* Branded so a validated id cannot be confused with an arbitrary string at a
|
|
48
|
+
* call site that matters.
|
|
49
|
+
*/
|
|
50
|
+
export type TaskId = string & { readonly __brand: 'TaskId' };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether `value` is a well-formed task identifier.
|
|
54
|
+
*
|
|
55
|
+
* @param value - candidate identifier.
|
|
56
|
+
* @returns `true` when `value` matches {@link TASK_ID_REGEX}.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* isTaskId('T12128'); // true
|
|
61
|
+
* isTaskId('/mnt/projects/cleo'); // false — the value that got written
|
|
62
|
+
* isTaskId('see T1234 for details');// false — anchoring matters
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function isTaskId(value: unknown): value is TaskId {
|
|
66
|
+
return typeof value === 'string' && TASK_ID_REGEX.test(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Characters and shapes that can never be a task identifier.
|
|
71
|
+
*
|
|
72
|
+
* ## Why this exists alongside {@link TASK_ID_REGEX}
|
|
73
|
+
*
|
|
74
|
+
* `TASK_ID_REGEX` describes the *canonical* id the generator produces. It is
|
|
75
|
+
* NOT what the store actually contains, and enforcing it at the write path
|
|
76
|
+
* would break working features. Measured against a live store of 3,198 tasks:
|
|
77
|
+
*
|
|
78
|
+
* | id | origin |
|
|
79
|
+
* |---|---|
|
|
80
|
+
* | `T-RECONCILE-FOLLOWUP-v2026.5.63-6` | generated ON PURPOSE by `archive-reason-invariant.ts` |
|
|
81
|
+
* | `T932EP` | unknown, pre-existing |
|
|
82
|
+
* | `/mnt/projects/cleocode` | the corruption this guard exists to stop |
|
|
83
|
+
*
|
|
84
|
+
* So CLEO deliberately mints structured ids that its own canonical pattern
|
|
85
|
+
* rejects — and, separately, that `cleo show` rejects too, which makes those
|
|
86
|
+
* release follow-up tasks as unreachable as the corrupt row. That is a real
|
|
87
|
+
* defect, but it is a **design** question about the id space, and settling it
|
|
88
|
+
* inside a guard that stops path-shaped garbage would be scope creep with a
|
|
89
|
+
* broken `cleo release reconcile` as the cost.
|
|
90
|
+
*
|
|
91
|
+
* This predicate therefore rejects only what cannot be an identifier under any
|
|
92
|
+
* shape: empty, whitespace, path separators, control characters, or absurd
|
|
93
|
+
* length. It catches the actual harm without taking a position on digits.
|
|
94
|
+
*/
|
|
95
|
+
const FORBIDDEN_IN_TASK_ID = /[\s/\\\x00-\x1f\x7f]/;
|
|
96
|
+
|
|
97
|
+
/** Longest plausible identifier; anything beyond this is not an id. */
|
|
98
|
+
const MAX_TASK_ID_LENGTH = 64;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether `value` is storable as a task identifier.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately broader than {@link isTaskId}: it admits every id shape CLEO
|
|
104
|
+
* actually mints (including the structured `T-RECONCILE-FOLLOWUP-…` ids the
|
|
105
|
+
* release reconciler creates) while rejecting values that are not identifiers
|
|
106
|
+
* at all. Use this at write chokepoints; use {@link isTaskId} when you
|
|
107
|
+
* genuinely need the canonical generated shape.
|
|
108
|
+
*
|
|
109
|
+
* @param value - candidate identifier.
|
|
110
|
+
* @returns `true` when `value` can be stored as an id.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* isStorableTaskId('T12128'); // true
|
|
115
|
+
* isStorableTaskId('T-RECONCILE-FOLLOWUP-v2026.5.63-6'); // true — CLEO mints these
|
|
116
|
+
* isStorableTaskId('/mnt/projects/cleocode'); // false — a path
|
|
117
|
+
* isStorableTaskId('see T1234 for details'); // false — whitespace
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function isStorableTaskId(value: unknown): value is TaskId {
|
|
121
|
+
if (typeof value !== 'string') return false;
|
|
122
|
+
if (value.length === 0 || value.length > MAX_TASK_ID_LENGTH) return false;
|
|
123
|
+
if (FORBIDDEN_IN_TASK_ID.test(value)) return false;
|
|
124
|
+
// An identifier must start with a letter — this rejects paths beginning
|
|
125
|
+
// `./`, `~`, or a digit-only value that is really a count.
|
|
126
|
+
return /^[A-Za-z]/.test(value);
|
|
127
|
+
}
|