@picoai/tickets 0.1.0 → 0.2.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/.tickets/spec/TICKETS.md +42 -15
- package/.tickets/spec/version/20260311-tickets-spec.md +38 -0
- package/README.md +47 -7
- package/package.json +3 -1
- package/release-history.json +12 -0
- package/src/cli.js +157 -39
- package/src/lib/constants.js +2 -2
- package/src/lib/listing.js +4 -4
- package/src/lib/repair.js +74 -0
- package/src/lib/util.js +5 -1
- package/src/lib/validation.js +56 -0
- package/src/release-status.js +141 -0
package/.tickets/spec/TICKETS.md
CHANGED
|
@@ -31,9 +31,9 @@ This repository uses a repo-native ticketing system designed for **parallel, lon
|
|
|
31
31
|
**TICKETS.md ** explains the workflow, file formats, and required tool usage for both humans and agents. If there is ever a conflict between this file and other docs, follow this file.
|
|
32
32
|
|
|
33
33
|
## Spec version
|
|
34
|
-
- `version`:
|
|
35
|
-
- `version_url`: `version/
|
|
36
|
-
- Local file: `/.tickets/spec/version/
|
|
34
|
+
- `version`: 2
|
|
35
|
+
- `version_url`: `version/20260311-tickets-spec.md`
|
|
36
|
+
- Local file: `/.tickets/spec/version/20260311-tickets-spec.md`
|
|
37
37
|
|
|
38
38
|
Version definitions live under `/.tickets/spec/version/`. Each spec file is self-contained and ends with a diff from the previous version.
|
|
39
39
|
|
|
@@ -63,7 +63,7 @@ This system addresses those problems with stable `ticket.md` files, merge-friend
|
|
|
63
63
|
### Initialize
|
|
64
64
|
Create the repo structure and templates (idempotent):
|
|
65
65
|
- `npx @picoai/tickets init`
|
|
66
|
-
- Add `--examples` to generate example tickets (7 sample tickets with required/optional fields, relationships, and logs).
|
|
66
|
+
- Add `--examples` to generate example tickets (7 sample tickets with required/optional fields, relationships, and logs that validate under the current spec).
|
|
67
67
|
- Add `--apply` to upsert/create the managed `## Ticketing Workflow` block in `AGENTS.md` from `AGENTS_EXAMPLE.md` (without creating `AGENTS_EXAMPLE.md` in the target repo).
|
|
68
68
|
- With `--apply`, `TICKETS.md` updates are marker-scoped: the managed block is replaced and metadata refreshed, while user-owned sections remain unchanged.
|
|
69
69
|
|
|
@@ -94,14 +94,25 @@ If validation fails and you want a complete report + repair plan:
|
|
|
94
94
|
- `npx @picoai/tickets validate --issues --all-fields > issues.yaml`
|
|
95
95
|
- `npx @picoai/tickets repair --issues-file issues.yaml --all-fields --non-interactive`
|
|
96
96
|
|
|
97
|
+
Expected handling loop for a ticket:
|
|
98
|
+
- validate the assigned ticket before changing code
|
|
99
|
+
- use `tickets status` when the lifecycle state changes (`todo`, `doing`, `blocked`, `done`, `canceled`)
|
|
100
|
+
- use `tickets log` for run history within a state: progress, checkpoints, blockers, decisions, verification, and handoff notes
|
|
101
|
+
- machine-written work logs must include `--context`; for split child bootstrapping, pair `--created-from <parent-id>` with `--context ...`
|
|
102
|
+
- reuse the same `--run-started` and `--run-id` for entries from the same run when you want a single per-run log file
|
|
103
|
+
|
|
97
104
|
### Log your work (human or agent)
|
|
98
105
|
Use the CLI to write logs whenever possible (merge-friendly, structured, and tooling-validated).
|
|
99
106
|
|
|
107
|
+
Actor defaults for both `tickets status` and `tickets log`:
|
|
108
|
+
- `actor_id`: `--actor-id`, else `TICKETS_ACTOR_ID`, else `@${USER|USERNAME}`, else `"unknown"`
|
|
109
|
+
- `actor_type`: `--actor-type`, else `TICKETS_ACTOR_TYPE`, else infer `agent` from `actor_id` prefix `agent:`, infer `human` from prefix `@`, else default to `human`
|
|
110
|
+
|
|
100
111
|
Agentic tools (including human-invoked tools like Cursor/Windsurf/Codex CLI/Claude Code) SHOULD log with `--machine`:
|
|
101
|
-
- `npx @picoai/tickets log --ticket <id> --
|
|
112
|
+
- `npx @picoai/tickets log --ticket <id> --summary "Implemented validator." --machine --actor-id "agent:cursor (human:@alice)" --context "Acceptance criteria from ticket" "API schema v2"`
|
|
102
113
|
|
|
103
114
|
Humans can log without machine marker:
|
|
104
|
-
- `npx @picoai/tickets log --ticket <id> --
|
|
115
|
+
- `npx @picoai/tickets log --ticket <id> --summary "Investigated failing test; will retry tomorrow."`
|
|
105
116
|
|
|
106
117
|
---
|
|
107
118
|
|
|
@@ -137,8 +148,8 @@ Example:
|
|
|
137
148
|
```md
|
|
138
149
|
---
|
|
139
150
|
id: 0191c2d3-4e5f-7a8b-9c0d-1e2f3a4b5c6d
|
|
140
|
-
version:
|
|
141
|
-
version_url: "version/
|
|
151
|
+
version: 2
|
|
152
|
+
version_url: "version/20260311-tickets-spec.md"
|
|
142
153
|
title: "Add tickets validate --issues"
|
|
143
154
|
status: todo
|
|
144
155
|
created_at: 2026-01-29T18:42:10Z
|
|
@@ -225,7 +236,8 @@ Recommended transitions:
|
|
|
225
236
|
- `done` and `canceled` are immutable unless explicitly reopened by a human (set to `doing` and log why).
|
|
226
237
|
|
|
227
238
|
Status updates:
|
|
228
|
-
- `npx @picoai/tickets status --ticket <id> --status doing --
|
|
239
|
+
- `npx @picoai/tickets status --ticket <id> --status doing --actor-type agent --actor-id "agent:codex"`
|
|
240
|
+
- `tickets status` always appends a machine-written status-change log entry.
|
|
229
241
|
|
|
230
242
|
---
|
|
231
243
|
|
|
@@ -254,7 +266,12 @@ Required fields:
|
|
|
254
266
|
- `actor_type`: `human|agent`
|
|
255
267
|
- `actor_id`: string identifier (freeform)
|
|
256
268
|
- `summary`: short summary string
|
|
269
|
+
- `event_type`: `status|work`
|
|
270
|
+
|
|
271
|
+
Conditional field:
|
|
257
272
|
- `context`: `[...]` (bullets capturing the context relevant to this run; when splitting, include copied/adapted inputs from the parent)
|
|
273
|
+
- required for machine-written `work` entries
|
|
274
|
+
- optional for `status` entries and human-written logs
|
|
258
275
|
|
|
259
276
|
Optional structured fields (recommended):
|
|
260
277
|
- `changes`: `{files: [...], commits: [...], prs: [...]}`
|
|
@@ -274,7 +291,7 @@ Validation strictness:
|
|
|
274
291
|
|
|
275
292
|
Example machine-written entry:
|
|
276
293
|
```json
|
|
277
|
-
{"version":
|
|
294
|
+
{"version":2,"version_url":"version/20260311-tickets-spec.md","ts":"2026-01-29T18:50:00Z","run_started":"20260129T184210.123Z","actor_type":"agent","actor_id":"agent:codex-cli (human:@alice)","summary":"Implemented tickets validate --issues.","event_type":"work","context":["Acceptance criteria from ticket","API schema v2"],"verification":{"commands":["npx @picoai/tickets validate"],"results":"pass"},"written_by":"tickets"}
|
|
278
295
|
```
|
|
279
296
|
|
|
280
297
|
Merge conflict rule (rare):
|
|
@@ -287,11 +304,15 @@ To keep state consistent and merge-friendly, agents and agentic tools SHOULD use
|
|
|
287
304
|
- Create tickets: `npx @picoai/tickets new`
|
|
288
305
|
- Validate: `npx @picoai/tickets validate` (or `--issues` for a full report)
|
|
289
306
|
- Repair: `npx @picoai/tickets repair --issues-file ...`
|
|
290
|
-
- Status changes: `npx @picoai/tickets status
|
|
307
|
+
- Status changes: `npx @picoai/tickets status`
|
|
291
308
|
- Work logs: `npx @picoai/tickets log` (use `--machine` when the entry is tooling-written)
|
|
292
309
|
- Listing/triage: `npx @picoai/tickets list` (use `--json` for automation)
|
|
293
310
|
|
|
294
311
|
Humans may edit `ticket.md` directly (it is designed for that), but logs should be appended via the CLI whenever feasible.
|
|
312
|
+
Expected command roles:
|
|
313
|
+
- `tickets status` changes canonical lifecycle state and always records that state transition in logs
|
|
314
|
+
- `tickets log` records run details without changing lifecycle state
|
|
315
|
+
- first child handoff after a split should be a `tickets log` entry with both `created_from` and `context`
|
|
295
316
|
|
|
296
317
|
---
|
|
297
318
|
|
|
@@ -328,10 +349,15 @@ Given a ticket assignment, an agent must:
|
|
|
328
349
|
|
|
329
350
|
1. Open the ticket file at `/.tickets/<ticket-id>/ticket.md`.
|
|
330
351
|
2. Identify acceptance criteria and verification steps from the ticket.
|
|
331
|
-
3.
|
|
332
|
-
4.
|
|
333
|
-
5.
|
|
334
|
-
6.
|
|
352
|
+
3. Validate the ticket before proceeding.
|
|
353
|
+
4. If beginning active work, set status to `doing`.
|
|
354
|
+
5. Plan minimally: determine the smallest change set that satisfies the acceptance criteria.
|
|
355
|
+
6. Implement within limits.
|
|
356
|
+
7. Verify using the ticket's verification steps (or reasonable defaults if absent).
|
|
357
|
+
8. Record progress with `tickets log`, reusing the same run metadata for the same run when appropriate.
|
|
358
|
+
- if the log is machine-written, include `context`
|
|
359
|
+
- if the ticket was created by splitting a parent, include `created_from` and the copied/adapted handoff bullets in `context`
|
|
360
|
+
9. If the run changes lifecycle state, use `tickets status` again (`blocked`, `done`, or explicit reassignment/reaffirmation):
|
|
335
361
|
- Write progress and outcomes to a per-run log file:
|
|
336
362
|
- `/.tickets/<ticket-id>/logs/<run_started>-<run_id>.jsonl`
|
|
337
363
|
- Include a `context` field capturing the relevant context for this run (copied/adapted inputs, decisions carried in, and any subticket handoff details).
|
|
@@ -409,6 +435,7 @@ repairs:
|
|
|
409
435
|
- `npx @picoai/tickets repair --issues-file <path>` applies enabled repairs:
|
|
410
436
|
- Safe repairs can be non-interactive.
|
|
411
437
|
- Disruptive repairs (like changing `id`) must have explicit decisions filled in (or require interactive mode).
|
|
438
|
+
- Basic log repairs are supported for missing/invalid `event_type` and invalid or missing `context` on machine-written work logs.
|
|
412
439
|
|
|
413
440
|
---
|
|
414
441
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Ticket Format Spec (Version 2)
|
|
2
|
+
|
|
3
|
+
- Version: 2
|
|
4
|
+
- Version URL: `version/20260311-tickets-spec.md`
|
|
5
|
+
- Released: 2026-03-11
|
|
6
|
+
- Status: current
|
|
7
|
+
|
|
8
|
+
## Definition (format only)
|
|
9
|
+
This version defines the ticket and log formats used by this repo. It does not define workflow policy; see `TICKETS.md` for full workflow.
|
|
10
|
+
|
|
11
|
+
### Ticket front matter (required)
|
|
12
|
+
- `id`: lowercase UUIDv7 string
|
|
13
|
+
- `version`: format version (integer)
|
|
14
|
+
- `version_url`: path to this definition (repo-local, relative to `.tickets/spec/`)
|
|
15
|
+
- `title`: string
|
|
16
|
+
- `status`: `todo|doing|blocked|done|canceled`
|
|
17
|
+
- `created_at`: ISO 8601 UTC timestamp
|
|
18
|
+
|
|
19
|
+
### Log entry (required)
|
|
20
|
+
- `version`: format version (integer)
|
|
21
|
+
- `version_url`: path to this definition (repo-local, relative to `.tickets/spec/`)
|
|
22
|
+
- `ts`: ISO 8601 UTC timestamp
|
|
23
|
+
- `run_started`: ISO 8601 UTC timestamp
|
|
24
|
+
- `actor_type`: `human|agent`
|
|
25
|
+
- `actor_id`: string
|
|
26
|
+
- `summary`: short string
|
|
27
|
+
- `event_type`: `status|work`
|
|
28
|
+
|
|
29
|
+
### Log entry (conditional)
|
|
30
|
+
- `context`: non-empty list of strings when `event_type: work` and the entry is machine-written
|
|
31
|
+
|
|
32
|
+
### Extensions
|
|
33
|
+
- Extensions are repo-local and must live under the `custom` key.
|
|
34
|
+
- Tools should ignore unknown keys under `custom`.
|
|
35
|
+
|
|
36
|
+
## Diff from previous version
|
|
37
|
+
- Added required `event_type` to log entries.
|
|
38
|
+
- Clarified that `context` is required for machine-written `work` entries, not for every log entry.
|
package/README.md
CHANGED
|
@@ -24,9 +24,9 @@ This system addresses those problems with stable `ticket.md` files, merge-friend
|
|
|
24
24
|
|
|
25
25
|
## Spec Version
|
|
26
26
|
|
|
27
|
-
- `version`:
|
|
28
|
-
- `version_url`: `version/
|
|
29
|
-
- Local file in package assets: `.tickets/spec/version/
|
|
27
|
+
- `version`: 2
|
|
28
|
+
- `version_url`: `version/20260311-tickets-spec.md`
|
|
29
|
+
- Local file in package assets: `.tickets/spec/version/20260311-tickets-spec.md`
|
|
30
30
|
|
|
31
31
|
Version definitions live under `.tickets/spec/version/`. Each spec file is self-contained and ends with a diff from the previous version.
|
|
32
32
|
|
|
@@ -58,6 +58,22 @@ npx @picoai/tickets init --apply
|
|
|
58
58
|
|
|
59
59
|
Repo-native ticketing CLI for Markdown-first, append-only ticket workflows.
|
|
60
60
|
|
|
61
|
+
## Release Provenance
|
|
62
|
+
|
|
63
|
+
- Latest npm release: `@picoai/tickets`
|
|
64
|
+
- Published from commit: `74b0378`
|
|
65
|
+
- Append-only release ledger: `packages/tickets/release-history.json`
|
|
66
|
+
|
|
67
|
+
Check current release posture locally:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm run release:status --workspace @picoai/tickets
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Recommended process:
|
|
74
|
+
- after an npm publish succeeds, append a new entry to `packages/tickets/release-history.json`
|
|
75
|
+
- use `npm run release:status --workspace @picoai/tickets` to see whether HEAD is ahead of the last recorded npm release and whether the package version still needs a bump
|
|
76
|
+
|
|
61
77
|
## Install
|
|
62
78
|
|
|
63
79
|
```bash
|
|
@@ -92,7 +108,7 @@ Initialize ticketing structure and templates.
|
|
|
92
108
|
npx @picoai/tickets init [--examples] [--apply]
|
|
93
109
|
```
|
|
94
110
|
|
|
95
|
-
- `--examples`: generate example tickets and logs.
|
|
111
|
+
- `--examples`: generate example tickets and logs that validate under the current spec.
|
|
96
112
|
- `--apply`: update managed `TICKETS.md` + `AGENTS.md` sections and skip `AGENTS_EXAMPLE.md` output.
|
|
97
113
|
|
|
98
114
|
### `new`
|
|
@@ -149,6 +165,10 @@ Options:
|
|
|
149
165
|
- `--non-interactive`
|
|
150
166
|
- `--all-fields`
|
|
151
167
|
|
|
168
|
+
Notes:
|
|
169
|
+
- `repair` fixes ticket-file issues and basic log issues.
|
|
170
|
+
- Basic log repairs cover missing/invalid `event_type` and invalid or missing `context` on machine-written work logs.
|
|
171
|
+
|
|
152
172
|
### `status`
|
|
153
173
|
|
|
154
174
|
Update ticket status.
|
|
@@ -159,19 +179,30 @@ npx @picoai/tickets status --ticket <ticket> --status <status> [options]
|
|
|
159
179
|
|
|
160
180
|
Options:
|
|
161
181
|
- `--status <status>` (`todo|doing|blocked|done|canceled`)
|
|
162
|
-
- `--
|
|
182
|
+
- `--actor-type <human|agent>`
|
|
183
|
+
- `--actor-id <id>`
|
|
184
|
+
- `--context <items...>`
|
|
163
185
|
- `--run-id <runId>`
|
|
164
186
|
- `--run-started <runStarted>`
|
|
165
187
|
|
|
188
|
+
Notes:
|
|
189
|
+
- `status` always appends a machine-written status-change log entry.
|
|
190
|
+
- include `--context` when the status transition depends on new context you want preserved in the audit trail.
|
|
191
|
+
- `actor_id` default order: `--actor-id`, `TICKETS_ACTOR_ID`, `@${USER|USERNAME}`, `"unknown"`.
|
|
192
|
+
- `actor_type` default order: `--actor-type`, `TICKETS_ACTOR_TYPE`, inferred from `actor_id` prefix (`agent:` -> `agent`, `@` -> `human`), then `human`.
|
|
193
|
+
|
|
166
194
|
### `log`
|
|
167
195
|
|
|
168
196
|
Append a run log entry.
|
|
169
197
|
|
|
170
198
|
```bash
|
|
171
|
-
npx @picoai/tickets log --ticket <ticket> --
|
|
199
|
+
npx @picoai/tickets log --ticket <ticket> --summary "<text>" [options]
|
|
172
200
|
```
|
|
173
201
|
|
|
174
202
|
Options:
|
|
203
|
+
- `--actor-type <human|agent>`
|
|
204
|
+
- `--actor-id <id>`
|
|
205
|
+
- `--context <items...>`
|
|
175
206
|
- `--run-id <runId>`
|
|
176
207
|
- `--run-started <runStarted>`
|
|
177
208
|
- `--machine`
|
|
@@ -181,10 +212,16 @@ Options:
|
|
|
181
212
|
- `--blockers <blockers...>`
|
|
182
213
|
- `--tickets-created <tickets...>`
|
|
183
214
|
- `--created-from <ticketId>`
|
|
184
|
-
- `--context-carried-over <items...>`
|
|
185
215
|
- `--verification-commands <commands...>`
|
|
186
216
|
- `--verification-results <results>`
|
|
187
217
|
|
|
218
|
+
Notes:
|
|
219
|
+
- `log` records run details without changing ticket lifecycle state.
|
|
220
|
+
- machine-written work logs require at least one `--context` item.
|
|
221
|
+
- for split child bootstrapping, use `--created-from <parent-ticket-id>` together with `--context ...`.
|
|
222
|
+
- `actor_id` default order: `--actor-id`, `TICKETS_ACTOR_ID`, `@${USER|USERNAME}`, `"unknown"`.
|
|
223
|
+
- `actor_type` default order: `--actor-type`, `TICKETS_ACTOR_TYPE`, inferred from `actor_id` prefix (`agent:` -> `agent`, `@` -> `human`), then `human`.
|
|
224
|
+
|
|
188
225
|
### `list`
|
|
189
226
|
|
|
190
227
|
List tickets with optional filters.
|
|
@@ -202,6 +239,9 @@ Options:
|
|
|
202
239
|
- `--text <text>`
|
|
203
240
|
- `--json`
|
|
204
241
|
|
|
242
|
+
Notes:
|
|
243
|
+
- `--text` searches the ticket title and Markdown body content.
|
|
244
|
+
|
|
205
245
|
### `graph`
|
|
206
246
|
|
|
207
247
|
Generate dependency graph output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@picoai/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Repo-native ticketing CLI and assets for Markdown-first, append-only ticket workflows.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"src",
|
|
13
|
+
"release-history.json",
|
|
13
14
|
".tickets/spec/AGENTS_EXAMPLE.md",
|
|
14
15
|
".tickets/spec/version",
|
|
15
16
|
".tickets/spec/TICKETS.md",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"tickets": "node ./bin/tickets.js",
|
|
22
|
+
"release:status": "node ./src/release-status.js",
|
|
21
23
|
"test": "node --test",
|
|
22
24
|
"test:cli": "node --test tests/tickets-cli.test.js",
|
|
23
25
|
"pack:check": "npm pack --dry-run --cache /tmp/npm-cache-tickets"
|
package/src/cli.js
CHANGED
|
@@ -40,6 +40,52 @@ function hasErrors(issues) {
|
|
|
40
40
|
return issues.some((issue) => issue.severity === "error");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function isValidActorType(value) {
|
|
44
|
+
return ["human", "agent"].includes(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveActorId(explicitActorId) {
|
|
48
|
+
const candidate = explicitActorId ?? process.env.TICKETS_ACTOR_ID;
|
|
49
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
50
|
+
return candidate.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localUser = process.env.USER ?? process.env.USERNAME;
|
|
54
|
+
if (typeof localUser === "string" && localUser.trim()) {
|
|
55
|
+
return `@${localUser.trim()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveActorType(explicitActorType, actorId) {
|
|
62
|
+
const candidate = explicitActorType ?? process.env.TICKETS_ACTOR_TYPE;
|
|
63
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
64
|
+
if (!isValidActorType(candidate.trim())) {
|
|
65
|
+
throw new Error("Invalid actor type. Use one of: human, agent");
|
|
66
|
+
}
|
|
67
|
+
return candidate.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (typeof actorId === "string") {
|
|
71
|
+
if (actorId.startsWith("agent:")) {
|
|
72
|
+
return "agent";
|
|
73
|
+
}
|
|
74
|
+
if (actorId.startsWith("@")) {
|
|
75
|
+
return "human";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return "human";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeContextItems(values) {
|
|
83
|
+
if (!Array.isArray(values)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return values.map((value) => String(value).trim()).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
function printIssues(issues) {
|
|
44
90
|
for (const issue of issues) {
|
|
45
91
|
const location = issue.ticket_path ?? issue.log ?? "";
|
|
@@ -65,11 +111,14 @@ function buildRepairsFromIssues(issues, options = {}) {
|
|
|
65
111
|
for (const issue of issues) {
|
|
66
112
|
const code = issue.code;
|
|
67
113
|
const ticketPath = issue.ticket_path;
|
|
68
|
-
|
|
114
|
+
const logLocation = issue.log;
|
|
115
|
+
const logPath = logLocation ? String(logLocation).replace(/:\d+$/, "") : null;
|
|
116
|
+
if (!ticketPath && !logPath) {
|
|
69
117
|
continue;
|
|
70
118
|
}
|
|
71
119
|
|
|
72
|
-
const
|
|
120
|
+
const targetPath = ticketPath ?? logPath;
|
|
121
|
+
const key = `${code}:${targetPath}`;
|
|
73
122
|
if (seen.has(key)) {
|
|
74
123
|
continue;
|
|
75
124
|
}
|
|
@@ -85,10 +134,31 @@ function buildRepairsFromIssues(issues, options = {}) {
|
|
|
85
134
|
id: nextId,
|
|
86
135
|
enabled: false,
|
|
87
136
|
issue_ids: [issue.id ?? ""],
|
|
88
|
-
ticket_path: ticketPath,
|
|
89
137
|
};
|
|
138
|
+
if (ticketPath) {
|
|
139
|
+
base.ticket_path = ticketPath;
|
|
140
|
+
}
|
|
141
|
+
if (logPath) {
|
|
142
|
+
base.log_path = logPath;
|
|
143
|
+
}
|
|
90
144
|
|
|
91
|
-
if (
|
|
145
|
+
if (["LOG_EVENT_TYPE_MISSING", "LOG_EVENT_TYPE_INVALID"].includes(code)) {
|
|
146
|
+
repairs.push({
|
|
147
|
+
...base,
|
|
148
|
+
safe: true,
|
|
149
|
+
action: "set_log_event_type",
|
|
150
|
+
params: {},
|
|
151
|
+
optional: false,
|
|
152
|
+
});
|
|
153
|
+
} else if (["CONTEXT_INVALID", "CONTEXT_EMPTY", "CONTEXT_ENTRY_INVALID", "CONTEXT_MISSING"].includes(code)) {
|
|
154
|
+
repairs.push({
|
|
155
|
+
...base,
|
|
156
|
+
safe: true,
|
|
157
|
+
action: "normalize_log_context",
|
|
158
|
+
params: {},
|
|
159
|
+
optional: false,
|
|
160
|
+
});
|
|
161
|
+
} else if (code === "MISSING_SECTION") {
|
|
92
162
|
repairs.push({ ...base, safe: true, action: "add_sections", params: {}, optional: false });
|
|
93
163
|
} else if (["VERSION_MISSING", "VERSION_INVALID"].includes(code)) {
|
|
94
164
|
repairs.push({
|
|
@@ -678,6 +748,7 @@ function generateExampleTickets() {
|
|
|
678
748
|
logs: [
|
|
679
749
|
{
|
|
680
750
|
summary: "Epic created and split into child tickets.",
|
|
751
|
+
context: ["Parent planning context for Feature Alpha", "Child tickets were split for parallel execution"],
|
|
681
752
|
tickets_created: ["backend", "frontend", "testing", "docs"],
|
|
682
753
|
next_steps: ["Coordinate release window", "Monitor blockers"],
|
|
683
754
|
},
|
|
@@ -709,7 +780,7 @@ function generateExampleTickets() {
|
|
|
709
780
|
summary: "Scaffolded API and outlined endpoints.",
|
|
710
781
|
decisions: ["Using UUID primary keys", "Respond with JSON:API style"],
|
|
711
782
|
created_from: "parent",
|
|
712
|
-
|
|
783
|
+
context: ["Acceptance criteria from parent", "Release target"],
|
|
713
784
|
},
|
|
714
785
|
],
|
|
715
786
|
},
|
|
@@ -732,7 +803,7 @@ function generateExampleTickets() {
|
|
|
732
803
|
summary: "Waiting on API responses to stabilize.",
|
|
733
804
|
blockers: ["Backend contract not finalized"],
|
|
734
805
|
created_from: "parent",
|
|
735
|
-
|
|
806
|
+
context: ["Design mocks v1.2", "API schema draft"],
|
|
736
807
|
},
|
|
737
808
|
],
|
|
738
809
|
},
|
|
@@ -754,7 +825,7 @@ function generateExampleTickets() {
|
|
|
754
825
|
summary: "Outlined E2E scenarios to automate.",
|
|
755
826
|
next_steps: ["Set up test data fixtures"],
|
|
756
827
|
created_from: "parent",
|
|
757
|
-
|
|
828
|
+
context: ["Frontend flow chart", "Backend contract v1"],
|
|
758
829
|
},
|
|
759
830
|
],
|
|
760
831
|
},
|
|
@@ -776,7 +847,7 @@ function generateExampleTickets() {
|
|
|
776
847
|
summary: "Preparing outline; waiting on test results.",
|
|
777
848
|
blockers: ["Integration tests pending"],
|
|
778
849
|
created_from: "parent",
|
|
779
|
-
|
|
850
|
+
context: ["Feature overview", "Known limitations"],
|
|
780
851
|
},
|
|
781
852
|
],
|
|
782
853
|
},
|
|
@@ -797,6 +868,7 @@ function generateExampleTickets() {
|
|
|
797
868
|
logs: [
|
|
798
869
|
{
|
|
799
870
|
summary: "Drafted release checklist; waiting on test green.",
|
|
871
|
+
context: ["Release checklist draft", "Waiting on integration test completion"],
|
|
800
872
|
next_steps: ["Book release window"],
|
|
801
873
|
},
|
|
802
874
|
],
|
|
@@ -818,6 +890,7 @@ function generateExampleTickets() {
|
|
|
818
890
|
logs: [
|
|
819
891
|
{
|
|
820
892
|
summary: "Blocked until backend fix lands.",
|
|
893
|
+
context: ["Regression repro identified", "Awaiting backend deployment before retry"],
|
|
821
894
|
blockers: ["Awaiting backend deployment"],
|
|
822
895
|
},
|
|
823
896
|
],
|
|
@@ -886,16 +959,17 @@ function generateExampleTickets() {
|
|
|
886
959
|
actor_type: "agent",
|
|
887
960
|
actor_id: "tickets-init",
|
|
888
961
|
summary: logSpec.summary,
|
|
962
|
+
event_type: "work",
|
|
889
963
|
written_by: "tickets",
|
|
890
964
|
};
|
|
891
965
|
|
|
892
966
|
for (const key of [
|
|
967
|
+
"context",
|
|
893
968
|
"decisions",
|
|
894
969
|
"next_steps",
|
|
895
970
|
"blockers",
|
|
896
971
|
"tickets_created",
|
|
897
972
|
"created_from",
|
|
898
|
-
"context_carried_over",
|
|
899
973
|
]) {
|
|
900
974
|
if (!(key in logSpec)) {
|
|
901
975
|
continue;
|
|
@@ -935,8 +1009,11 @@ async function cmdInit(options) {
|
|
|
935
1009
|
const versionDir = path.join(repoBaseDir, "version");
|
|
936
1010
|
ensureDir(versionDir);
|
|
937
1011
|
|
|
938
|
-
const
|
|
939
|
-
writeTemplateFile(
|
|
1012
|
+
const currentSpecPath = path.join(versionDir, "20260311-tickets-spec.md");
|
|
1013
|
+
writeTemplateFile(currentSpecPath, path.join(".tickets", "spec", "version", "20260311-tickets-spec.md"), apply);
|
|
1014
|
+
|
|
1015
|
+
const previousSpecPath = path.join(versionDir, "20260205-tickets-spec.md");
|
|
1016
|
+
writeTemplateFile(previousSpecPath, path.join(".tickets", "spec", "version", "20260205-tickets-spec.md"), apply);
|
|
940
1017
|
|
|
941
1018
|
const proposedPath = path.join(versionDir, "PROPOSED-tickets-spec.md");
|
|
942
1019
|
writeTemplateFile(proposedPath, path.join(".tickets", "spec", "version", "PROPOSED-tickets-spec.md"), apply);
|
|
@@ -1085,28 +1162,37 @@ async function cmdValidate(options) {
|
|
|
1085
1162
|
async function cmdStatus(options) {
|
|
1086
1163
|
const ticketPath = resolveTicketPath(options.ticket);
|
|
1087
1164
|
const [frontMatter, body] = loadTicket(ticketPath);
|
|
1165
|
+
const previousStatus = frontMatter.status;
|
|
1166
|
+
const actorId = resolveActorId(options.actorId);
|
|
1167
|
+
const actorType = resolveActorType(options.actorType, actorId);
|
|
1168
|
+
const context = normalizeContextItems(options.context);
|
|
1088
1169
|
|
|
1089
1170
|
frontMatter.status = options.status;
|
|
1090
1171
|
writeTicket(ticketPath, frontMatter, body);
|
|
1091
1172
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1173
|
+
const runId = options.runId || newUuidv7();
|
|
1174
|
+
const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
|
|
1175
|
+
const entry = {
|
|
1176
|
+
version: FORMAT_VERSION,
|
|
1177
|
+
version_url: FORMAT_VERSION_URL,
|
|
1178
|
+
ts: iso8601(nowUtc()),
|
|
1179
|
+
run_started: runStarted,
|
|
1180
|
+
actor_type: actorType,
|
|
1181
|
+
actor_id: actorId,
|
|
1182
|
+
summary:
|
|
1183
|
+
previousStatus === options.status
|
|
1184
|
+
? `Status reaffirmed as ${options.status}`
|
|
1185
|
+
: `Status changed from ${previousStatus ?? "unknown"} to ${options.status}`,
|
|
1186
|
+
event_type: "status",
|
|
1187
|
+
written_by: "tickets",
|
|
1188
|
+
};
|
|
1189
|
+
if (context.length > 0) {
|
|
1190
|
+
entry.context = context;
|
|
1108
1191
|
}
|
|
1109
1192
|
|
|
1193
|
+
const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
|
|
1194
|
+
appendJsonl(logPath, entry);
|
|
1195
|
+
|
|
1110
1196
|
return 0;
|
|
1111
1197
|
}
|
|
1112
1198
|
|
|
@@ -1114,16 +1200,26 @@ async function cmdLog(options) {
|
|
|
1114
1200
|
const ticketPath = resolveTicketPath(options.ticket);
|
|
1115
1201
|
const runId = options.runId || newUuidv7();
|
|
1116
1202
|
const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
|
|
1203
|
+
const actorId = resolveActorId(options.actorId);
|
|
1204
|
+
const actorType = resolveActorType(options.actorType, actorId);
|
|
1205
|
+
const context = normalizeContextItems(options.context);
|
|
1206
|
+
if (options.machine && context.length === 0) {
|
|
1207
|
+
throw new Error("Machine-written work logs require at least one --context item");
|
|
1208
|
+
}
|
|
1117
1209
|
|
|
1118
1210
|
const entry = {
|
|
1119
1211
|
version: FORMAT_VERSION,
|
|
1120
1212
|
version_url: FORMAT_VERSION_URL,
|
|
1121
1213
|
ts: iso8601(nowUtc()),
|
|
1122
1214
|
run_started: runStarted,
|
|
1123
|
-
actor_type:
|
|
1124
|
-
actor_id:
|
|
1215
|
+
actor_type: actorType,
|
|
1216
|
+
actor_id: actorId,
|
|
1125
1217
|
summary: options.summary,
|
|
1218
|
+
event_type: "work",
|
|
1126
1219
|
};
|
|
1220
|
+
if (context.length > 0) {
|
|
1221
|
+
entry.context = context;
|
|
1222
|
+
}
|
|
1127
1223
|
|
|
1128
1224
|
if (options.machine) {
|
|
1129
1225
|
entry.written_by = "tickets";
|
|
@@ -1146,9 +1242,6 @@ async function cmdLog(options) {
|
|
|
1146
1242
|
if (options.createdFrom) {
|
|
1147
1243
|
entry.created_from = options.createdFrom;
|
|
1148
1244
|
}
|
|
1149
|
-
if (options.contextCarriedOver?.length) {
|
|
1150
|
-
entry.context_carried_over = options.contextCarriedOver;
|
|
1151
|
-
}
|
|
1152
1245
|
if (options.verificationCommands?.length || options.verificationResults) {
|
|
1153
1246
|
entry.verification = {
|
|
1154
1247
|
commands: options.verificationCommands || [],
|
|
@@ -1226,6 +1319,24 @@ async function cmdRepair(options) {
|
|
|
1226
1319
|
autoEnableSafe: !options.interactive,
|
|
1227
1320
|
}),
|
|
1228
1321
|
);
|
|
1322
|
+
|
|
1323
|
+
const logsDir = path.join(path.dirname(ticketPath), "logs");
|
|
1324
|
+
if (!fs.existsSync(logsDir)) {
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const logFiles = fs
|
|
1328
|
+
.readdirSync(logsDir, { withFileTypes: true })
|
|
1329
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
1330
|
+
.map((entry) => path.join(logsDir, entry.name))
|
|
1331
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1332
|
+
for (const logFile of logFiles) {
|
|
1333
|
+
repairs.push(
|
|
1334
|
+
...buildRepairsFromIssues(validateRunLog(logFile, false), {
|
|
1335
|
+
includeOptional: options.allFields,
|
|
1336
|
+
autoEnableSafe: !options.interactive,
|
|
1337
|
+
}),
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1229
1340
|
}
|
|
1230
1341
|
|
|
1231
1342
|
const changes = options.interactive
|
|
@@ -1364,17 +1475,24 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
1364
1475
|
.description("Update ticket status")
|
|
1365
1476
|
.requiredOption("--ticket <ticket>")
|
|
1366
1477
|
.requiredOption("--status <status>")
|
|
1367
|
-
.option("--
|
|
1478
|
+
.option("--actor-type <actorType>")
|
|
1479
|
+
.option("--actor-id <actorId>")
|
|
1480
|
+
.option("--context <items...>")
|
|
1368
1481
|
.option("--run-id <runId>")
|
|
1369
1482
|
.option("--run-started <runStarted>")
|
|
1370
1483
|
.action(async (options) => {
|
|
1371
1484
|
if (!STATUS_VALUES.includes(options.status)) {
|
|
1372
1485
|
throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
|
|
1373
1486
|
}
|
|
1487
|
+
if (options.actorType && !isValidActorType(options.actorType)) {
|
|
1488
|
+
throw new Error("Invalid --actor-type. Use one of: human, agent");
|
|
1489
|
+
}
|
|
1374
1490
|
process.exitCode = await cmdStatus({
|
|
1375
1491
|
ticket: options.ticket,
|
|
1376
1492
|
status: options.status,
|
|
1377
|
-
|
|
1493
|
+
actorType: options.actorType,
|
|
1494
|
+
actorId: options.actorId,
|
|
1495
|
+
context: options.context,
|
|
1378
1496
|
runId: options.runId,
|
|
1379
1497
|
runStarted: options.runStarted,
|
|
1380
1498
|
});
|
|
@@ -1386,8 +1504,8 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
1386
1504
|
.requiredOption("--ticket <ticket>")
|
|
1387
1505
|
.option("--run-id <runId>")
|
|
1388
1506
|
.option("--run-started <runStarted>")
|
|
1389
|
-
.
|
|
1390
|
-
.
|
|
1507
|
+
.option("--actor-type <actorType>")
|
|
1508
|
+
.option("--actor-id <actorId>")
|
|
1391
1509
|
.requiredOption("--summary <summary>")
|
|
1392
1510
|
.option("--machine")
|
|
1393
1511
|
.option("--changes <files...>")
|
|
@@ -1396,11 +1514,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
1396
1514
|
.option("--blockers <blockers...>")
|
|
1397
1515
|
.option("--tickets-created <tickets...>")
|
|
1398
1516
|
.option("--created-from <ticketId>")
|
|
1399
|
-
.option("--context
|
|
1517
|
+
.option("--context <items...>")
|
|
1400
1518
|
.option("--verification-commands <commands...>")
|
|
1401
1519
|
.option("--verification-results <results>")
|
|
1402
1520
|
.action(async (options) => {
|
|
1403
|
-
if (!
|
|
1521
|
+
if (options.actorType && !isValidActorType(options.actorType)) {
|
|
1404
1522
|
throw new Error("Invalid --actor-type. Use one of: human, agent");
|
|
1405
1523
|
}
|
|
1406
1524
|
process.exitCode = await cmdLog({
|
|
@@ -1417,7 +1535,7 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
1417
1535
|
blockers: options.blockers,
|
|
1418
1536
|
ticketsCreated: options.ticketsCreated,
|
|
1419
1537
|
createdFrom: options.createdFrom,
|
|
1420
|
-
|
|
1538
|
+
context: options.context,
|
|
1421
1539
|
verificationCommands: options.verificationCommands,
|
|
1422
1540
|
verificationResults: options.verificationResults,
|
|
1423
1541
|
});
|
package/src/lib/constants.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const BASE_DIR = ".tickets/spec";
|
|
2
|
-
export const FORMAT_VERSION =
|
|
3
|
-
export const FORMAT_VERSION_URL = "version/
|
|
2
|
+
export const FORMAT_VERSION = 2;
|
|
3
|
+
export const FORMAT_VERSION_URL = "version/20260311-tickets-spec.md";
|
|
4
4
|
|
|
5
5
|
export const STATUS_VALUES = ["todo", "doing", "blocked", "done", "canceled"];
|
|
6
6
|
export const PRIORITY_VALUES = ["low", "medium", "high", "critical"];
|
package/src/lib/listing.js
CHANGED
|
@@ -8,11 +8,11 @@ export function listTickets(filters) {
|
|
|
8
8
|
const rows = [];
|
|
9
9
|
|
|
10
10
|
for (const ticketPath of collectTicketPaths(null)) {
|
|
11
|
-
const [, frontMatter] = validateTicket(ticketPath);
|
|
11
|
+
const [, frontMatter, body] = validateTicket(ticketPath);
|
|
12
12
|
if (!frontMatter || Object.keys(frontMatter).length === 0) {
|
|
13
13
|
continue;
|
|
14
14
|
}
|
|
15
|
-
if (!passesFilters(frontMatter, filters)) {
|
|
15
|
+
if (!passesFilters(frontMatter, body, filters)) {
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ export function listTickets(filters) {
|
|
|
30
30
|
return rows;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function passesFilters(frontMatter, filters) {
|
|
33
|
+
function passesFilters(frontMatter, body, filters) {
|
|
34
34
|
if (filters.status && frontMatter.status !== filters.status) {
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
@@ -51,7 +51,7 @@ function passesFilters(frontMatter, filters) {
|
|
|
51
51
|
}
|
|
52
52
|
if (filters.text) {
|
|
53
53
|
const text = String(filters.text).toLowerCase();
|
|
54
|
-
const haystack = `${frontMatter.title ?? ""}\n${
|
|
54
|
+
const haystack = `${frontMatter.title ?? ""}\n${body ?? ""}`.toLowerCase();
|
|
55
55
|
if (!haystack.includes(text)) {
|
|
56
56
|
return false;
|
|
57
57
|
}
|
package/src/lib/repair.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
newUuidv7,
|
|
15
15
|
nowUtc,
|
|
16
16
|
parseIso,
|
|
17
|
+
readJsonl,
|
|
17
18
|
writeTicket,
|
|
18
19
|
} from "./util.js";
|
|
19
20
|
|
|
@@ -37,6 +38,7 @@ export async function applyRepairs(repairs, options = {}) {
|
|
|
37
38
|
const action = repair.action;
|
|
38
39
|
const params = repair.params ?? {};
|
|
39
40
|
const ticketPath = repair.ticket_path;
|
|
41
|
+
const logPath = repair.log_path;
|
|
40
42
|
|
|
41
43
|
if (action === "set_front_matter_field") {
|
|
42
44
|
const field = params.field;
|
|
@@ -91,6 +93,18 @@ export async function applyRepairs(repairs, options = {}) {
|
|
|
91
93
|
continue;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
if (action === "set_log_event_type") {
|
|
97
|
+
setLogEventType(logPath);
|
|
98
|
+
applied.push(`${logPath}: set event_type`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (action === "normalize_log_context") {
|
|
103
|
+
normalizeLogContext(logPath);
|
|
104
|
+
applied.push(`${logPath}: normalized context`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
if (nonInteractive) {
|
|
95
109
|
throw new Error(`Unsupported repair action ${action}`);
|
|
96
110
|
}
|
|
@@ -147,6 +161,7 @@ function describeRepair(repair) {
|
|
|
147
161
|
const action = repair.action;
|
|
148
162
|
const field = repair.params?.field;
|
|
149
163
|
const ticketPath = repair.ticket_path ?? "";
|
|
164
|
+
const logPath = repair.log_path ?? "";
|
|
150
165
|
const value = repair.params?.value;
|
|
151
166
|
|
|
152
167
|
if (action === "add_sections") {
|
|
@@ -185,6 +200,12 @@ function describeRepair(repair) {
|
|
|
185
200
|
if (action === "normalize_verification_commands") {
|
|
186
201
|
return ["Normalize verification.commands to strings, dropping invalid entries.", null];
|
|
187
202
|
}
|
|
203
|
+
if (action === "set_log_event_type") {
|
|
204
|
+
return [`Set event_type in ${logPath} based on the log entry shape.`, null];
|
|
205
|
+
}
|
|
206
|
+
if (action === "normalize_log_context") {
|
|
207
|
+
return [`Normalize context in ${logPath} and synthesize a minimal fallback when required.`, null];
|
|
208
|
+
}
|
|
188
209
|
return ["Apply repair", value];
|
|
189
210
|
}
|
|
190
211
|
|
|
@@ -336,3 +357,56 @@ function normalizeVerificationCommands(ticketPath) {
|
|
|
336
357
|
};
|
|
337
358
|
writeTicket(ticketPath, frontMatter, body);
|
|
338
359
|
}
|
|
360
|
+
|
|
361
|
+
function setLogEventType(logPath) {
|
|
362
|
+
updateJsonl(logPath, (entry) => {
|
|
363
|
+
entry.event_type = inferLogEventType(entry);
|
|
364
|
+
return entry;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function normalizeLogContext(logPath) {
|
|
369
|
+
updateJsonl(logPath, (entry) => {
|
|
370
|
+
if (!Array.isArray(entry.context)) {
|
|
371
|
+
entry.context = [];
|
|
372
|
+
}
|
|
373
|
+
entry.context = entry.context.map((value) => String(value).trim()).filter(Boolean);
|
|
374
|
+
|
|
375
|
+
const machineEntry = entry.written_by === "tickets" || entry.machine === true;
|
|
376
|
+
const eventType = inferLogEventType(entry);
|
|
377
|
+
if (entry.event_type !== eventType) {
|
|
378
|
+
entry.event_type = eventType;
|
|
379
|
+
}
|
|
380
|
+
if (machineEntry && eventType === "work" && entry.context.length === 0) {
|
|
381
|
+
entry.context = buildFallbackContext(entry);
|
|
382
|
+
}
|
|
383
|
+
return entry;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildFallbackContext(entry) {
|
|
388
|
+
const items = [];
|
|
389
|
+
if (typeof entry.created_from === "string" && entry.created_from.trim()) {
|
|
390
|
+
items.push(`Handoff from parent ticket ${entry.created_from.trim()}.`);
|
|
391
|
+
}
|
|
392
|
+
if (typeof entry.summary === "string" && entry.summary.trim()) {
|
|
393
|
+
items.push(`Recovered from summary: ${entry.summary.trim()}`);
|
|
394
|
+
}
|
|
395
|
+
if (items.length === 0) {
|
|
396
|
+
items.push("Recovered context was unavailable during automated repair.");
|
|
397
|
+
}
|
|
398
|
+
return items;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function inferLogEventType(entry) {
|
|
402
|
+
if (typeof entry.summary === "string" && /^Status (changed|reaffirmed)\b/.test(entry.summary)) {
|
|
403
|
+
return "status";
|
|
404
|
+
}
|
|
405
|
+
return "work";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function updateJsonl(logPath, updateEntry) {
|
|
409
|
+
const entries = readJsonl(logPath);
|
|
410
|
+
const content = entries.map((entry) => JSON.stringify(updateEntry(entry))).join("\n");
|
|
411
|
+
fs.writeFileSync(logPath, `${content}\n`);
|
|
412
|
+
}
|
package/src/lib/util.js
CHANGED
|
@@ -49,7 +49,11 @@ export function parseIso(value) {
|
|
|
49
49
|
if (typeof value !== "string") {
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
|
-
const
|
|
52
|
+
const basicMatch = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\.\d+)?Z$/.exec(value);
|
|
53
|
+
const normalized = basicMatch
|
|
54
|
+
? `${basicMatch[1]}-${basicMatch[2]}-${basicMatch[3]}T${basicMatch[4]}:${basicMatch[5]}:${basicMatch[6]}${basicMatch[7] ?? ""}Z`
|
|
55
|
+
: value;
|
|
56
|
+
const ts = Date.parse(normalized);
|
|
53
57
|
return Number.isNaN(ts) ? null : new Date(ts);
|
|
54
58
|
}
|
|
55
59
|
|
package/src/lib/validation.js
CHANGED
|
@@ -360,6 +360,7 @@ export function validateRunLog(logPath, machineStrictDefault) {
|
|
|
360
360
|
const loc = `${logPath}:${idx + 1}`;
|
|
361
361
|
const machineEntry =
|
|
362
362
|
machineStrictDefault || entry.written_by === "tickets" || entry.machine === true;
|
|
363
|
+
let eventType = null;
|
|
363
364
|
|
|
364
365
|
for (const required of ["ts", "run_started", "actor_type", "actor_id", "summary"]) {
|
|
365
366
|
if (!(required in entry)) {
|
|
@@ -372,6 +373,24 @@ export function validateRunLog(logPath, machineStrictDefault) {
|
|
|
372
373
|
}
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
if (!("event_type" in entry)) {
|
|
377
|
+
issues.push({
|
|
378
|
+
severity: machineEntry ? "error" : "warning",
|
|
379
|
+
code: "LOG_EVENT_TYPE_MISSING",
|
|
380
|
+
message: "event_type missing",
|
|
381
|
+
log: loc,
|
|
382
|
+
});
|
|
383
|
+
} else if (!["status", "work"].includes(entry.event_type)) {
|
|
384
|
+
issues.push({
|
|
385
|
+
severity: machineEntry ? "error" : "warning",
|
|
386
|
+
code: "LOG_EVENT_TYPE_INVALID",
|
|
387
|
+
message: "event_type must be status|work",
|
|
388
|
+
log: loc,
|
|
389
|
+
});
|
|
390
|
+
} else {
|
|
391
|
+
eventType = entry.event_type;
|
|
392
|
+
}
|
|
393
|
+
|
|
375
394
|
if (!("version" in entry)) {
|
|
376
395
|
issues.push({
|
|
377
396
|
severity: machineEntry ? "error" : "warning",
|
|
@@ -459,6 +478,43 @@ export function validateRunLog(logPath, machineStrictDefault) {
|
|
|
459
478
|
});
|
|
460
479
|
}
|
|
461
480
|
|
|
481
|
+
if ("context" in entry) {
|
|
482
|
+
if (!Array.isArray(entry.context)) {
|
|
483
|
+
issues.push({
|
|
484
|
+
severity: machineEntry ? "error" : "warning",
|
|
485
|
+
code: "CONTEXT_INVALID",
|
|
486
|
+
message: "context must be a list of strings",
|
|
487
|
+
log: loc,
|
|
488
|
+
});
|
|
489
|
+
} else {
|
|
490
|
+
if (machineEntry && eventType === "work" && entry.context.length === 0) {
|
|
491
|
+
issues.push({
|
|
492
|
+
severity: "error",
|
|
493
|
+
code: "CONTEXT_EMPTY",
|
|
494
|
+
message: "context must contain at least one item for machine-written work logs",
|
|
495
|
+
log: loc,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
for (const item of entry.context) {
|
|
499
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
500
|
+
issues.push({
|
|
501
|
+
severity: machineEntry ? "error" : "warning",
|
|
502
|
+
code: "CONTEXT_ENTRY_INVALID",
|
|
503
|
+
message: "context entries must be non-empty strings",
|
|
504
|
+
log: loc,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} else if (machineEntry && eventType === "work") {
|
|
510
|
+
issues.push({
|
|
511
|
+
severity: "error",
|
|
512
|
+
code: "CONTEXT_MISSING",
|
|
513
|
+
message: "context required for machine-written work logs",
|
|
514
|
+
log: loc,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
462
518
|
if (machineEntry && entry.written_by !== "tickets" && entry.machine !== true) {
|
|
463
519
|
issues.push({
|
|
464
520
|
severity: "error",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
function packageRoot() {
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
return path.resolve(here, "..");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readJson(filePath) {
|
|
12
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runGit(args) {
|
|
16
|
+
const result = spawnSync("git", args, {
|
|
17
|
+
cwd: packageRoot(),
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
});
|
|
20
|
+
if (result.status !== 0) {
|
|
21
|
+
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
|
|
22
|
+
}
|
|
23
|
+
return result.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function summarizeReleaseStatus({
|
|
27
|
+
packageName,
|
|
28
|
+
currentVersion,
|
|
29
|
+
headCommit,
|
|
30
|
+
dirty,
|
|
31
|
+
latestRelease,
|
|
32
|
+
}) {
|
|
33
|
+
const messages = [];
|
|
34
|
+
const publishedVersion = latestRelease?.version ?? "unknown";
|
|
35
|
+
const publishedCommit = latestRelease?.commit ?? "unknown";
|
|
36
|
+
const sameCommit = latestRelease ? headCommit === latestRelease.commit : false;
|
|
37
|
+
const sameVersion = latestRelease ? currentVersion === latestRelease.version : false;
|
|
38
|
+
|
|
39
|
+
if (!latestRelease) {
|
|
40
|
+
messages.push("No published release is recorded yet.");
|
|
41
|
+
} else if (sameCommit && sameVersion && !dirty) {
|
|
42
|
+
messages.push("HEAD matches the latest recorded npm release.");
|
|
43
|
+
} else {
|
|
44
|
+
if (!sameCommit) {
|
|
45
|
+
messages.push(`HEAD (${headCommit}) is ahead of the latest recorded release commit (${publishedCommit}).`);
|
|
46
|
+
}
|
|
47
|
+
if (!sameVersion) {
|
|
48
|
+
messages.push(
|
|
49
|
+
`Package version (${currentVersion}) differs from the latest recorded release version (${publishedVersion}).`,
|
|
50
|
+
);
|
|
51
|
+
} else if (!sameCommit) {
|
|
52
|
+
messages.push("Package version has not changed since the latest recorded npm release.");
|
|
53
|
+
}
|
|
54
|
+
if (dirty) {
|
|
55
|
+
messages.push("Working tree has uncommitted changes.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let recommendation;
|
|
60
|
+
if (!latestRelease) {
|
|
61
|
+
recommendation = "Record the first published release after npm publish.";
|
|
62
|
+
} else if (dirty) {
|
|
63
|
+
recommendation = "Commit or discard local changes before deciding whether to publish.";
|
|
64
|
+
} else if (!sameCommit && sameVersion) {
|
|
65
|
+
recommendation = "If this work should ship, bump the package version before publishing.";
|
|
66
|
+
} else if (!sameCommit && !sameVersion) {
|
|
67
|
+
recommendation = "If tests and docs are ready, HEAD is a plausible publish candidate.";
|
|
68
|
+
} else {
|
|
69
|
+
recommendation = "No publish action is indicated by release provenance alone.";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
packageName,
|
|
74
|
+
currentVersion,
|
|
75
|
+
headCommit,
|
|
76
|
+
dirty,
|
|
77
|
+
latestRelease,
|
|
78
|
+
messages,
|
|
79
|
+
recommendation,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function loadReleaseStatus() {
|
|
84
|
+
const root = packageRoot();
|
|
85
|
+
const packageJson = readJson(path.join(root, "package.json"));
|
|
86
|
+
const history = readJson(path.join(root, "release-history.json"));
|
|
87
|
+
const releases = Array.isArray(history.releases) ? history.releases : [];
|
|
88
|
+
const latestRelease = releases.length > 0 ? releases[releases.length - 1] : null;
|
|
89
|
+
const headCommit = runGit(["rev-parse", "--short", "HEAD"]);
|
|
90
|
+
const dirty = runGit(["status", "--short"]).length > 0;
|
|
91
|
+
|
|
92
|
+
return summarizeReleaseStatus({
|
|
93
|
+
packageName: packageJson.name,
|
|
94
|
+
currentVersion: packageJson.version,
|
|
95
|
+
headCommit,
|
|
96
|
+
dirty,
|
|
97
|
+
latestRelease,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function formatReleaseStatus(status) {
|
|
102
|
+
const lines = [
|
|
103
|
+
`Package: ${status.packageName}`,
|
|
104
|
+
`Current version: ${status.currentVersion}`,
|
|
105
|
+
`Current HEAD: ${status.headCommit}`,
|
|
106
|
+
`Working tree dirty: ${status.dirty ? "yes" : "no"}`,
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
if (status.latestRelease) {
|
|
110
|
+
lines.push(`Latest recorded npm release: ${status.latestRelease.version}`);
|
|
111
|
+
lines.push(`Latest recorded release commit: ${status.latestRelease.commit}`);
|
|
112
|
+
if (status.latestRelease.date) {
|
|
113
|
+
lines.push(`Latest recorded release date: ${status.latestRelease.date}`);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
lines.push("Latest recorded npm release: none");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push("Status:");
|
|
121
|
+
for (const message of status.messages) {
|
|
122
|
+
lines.push(`- ${message}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push(`- Recommendation: ${status.recommendation}`);
|
|
125
|
+
return `${lines.join("\n")}\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function main() {
|
|
129
|
+
try {
|
|
130
|
+
const status = loadReleaseStatus();
|
|
131
|
+
process.stdout.write(formatReleaseStatus(status));
|
|
132
|
+
process.exitCode = 0;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
process.stderr.write(`${String(error.message ?? error)}\n`);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
140
|
+
await main();
|
|
141
|
+
}
|