wood-fired-tasks 2.1.1 → 2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +57 -3
  2. package/README.md +2 -1
  3. package/SECURITY.md +2 -2
  4. package/dist/api/api-response.d.ts +4 -0
  5. package/dist/api/routes/models/index.d.ts +16 -0
  6. package/dist/api/routes/models/index.js +1 -7
  7. package/dist/api/routes/projects/resolve-model.js +17 -8
  8. package/dist/api/routes/projects/wsjf.js +4 -0
  9. package/dist/api/routes/settings/model-policy.js +8 -4
  10. package/dist/api/routes/tasks/schemas.d.ts +8 -0
  11. package/dist/api/routes/tasks/schemas.js +10 -0
  12. package/dist/api/server.d.ts +1 -1
  13. package/dist/api/server.js +6 -19
  14. package/dist/cli/api/client.d.ts +7 -7
  15. package/dist/cli/api/types.d.ts +6 -0
  16. package/dist/cli/auth/browser-open.js +0 -11
  17. package/dist/cli/auth/credentials.d.ts +19 -0
  18. package/dist/cli/auth/credentials.js +30 -0
  19. package/dist/cli/commands/comment-add.js +8 -1
  20. package/dist/cli/commands/create.js +9 -1
  21. package/dist/cli/commands/list.js +30 -7
  22. package/dist/cli/commands/login.d.ts +10 -0
  23. package/dist/cli/commands/login.js +2 -1
  24. package/dist/cli/commands/models.d.ts +38 -3
  25. package/dist/cli/commands/models.js +48 -4
  26. package/dist/cli/commands/project-set-models.js +6 -19
  27. package/dist/cli/commands/settings-set-models.js +6 -19
  28. package/dist/cli/commands/setup.d.ts +8 -0
  29. package/dist/cli/commands/setup.js +1 -0
  30. package/dist/cli/commands/update.js +20 -0
  31. package/dist/events/event-bus.d.ts +2 -1
  32. package/dist/events/event-bus.js +1 -0
  33. package/dist/events/sse-manager.d.ts +30 -1
  34. package/dist/events/sse-manager.js +100 -18
  35. package/dist/events/types.d.ts +19 -2
  36. package/dist/events/types.js +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.js +36 -1
  39. package/dist/mcp/index.js +5 -24
  40. package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
  41. package/dist/mcp/lib/model-tool-definitions.js +112 -0
  42. package/dist/mcp/remote/register-tools.js +14 -73
  43. package/dist/mcp/remote/rest-client.d.ts +4 -10
  44. package/dist/mcp/resources/events.js +2 -1
  45. package/dist/mcp/tools/health-tools.d.ts +14 -0
  46. package/dist/mcp/tools/health-tools.js +52 -1
  47. package/dist/mcp/tools/model-tools.d.ts +4 -3
  48. package/dist/mcp/tools/model-tools.js +13 -82
  49. package/dist/mcp/tools/task-tools.js +5 -1
  50. package/dist/repositories/interfaces.d.ts +39 -0
  51. package/dist/repositories/project-charter-history.repository.js +2 -12
  52. package/dist/repositories/project.repository.js +16 -48
  53. package/dist/repositories/task.repository.d.ts +48 -0
  54. package/dist/repositories/task.repository.js +90 -18
  55. package/dist/repositories/wsjf-history.repository.js +6 -16
  56. package/dist/schemas/model-catalog.schema.d.ts +22 -0
  57. package/dist/schemas/model-catalog.schema.js +21 -0
  58. package/dist/schemas/model-policy.schema.d.ts +14 -0
  59. package/dist/schemas/model-policy.schema.js +11 -0
  60. package/dist/schemas/task.schema.d.ts +13 -1
  61. package/dist/schemas/task.schema.js +11 -0
  62. package/dist/services/claim-release.service.d.ts +11 -0
  63. package/dist/services/claim-release.service.js +31 -2
  64. package/dist/services/job-size-backfill.d.ts +43 -0
  65. package/dist/services/job-size-backfill.js +81 -0
  66. package/dist/services/model-catalog.service.d.ts +28 -8
  67. package/dist/services/model-catalog.service.js +16 -4
  68. package/dist/services/model-policy.service.d.ts +87 -9
  69. package/dist/services/model-policy.service.js +96 -4
  70. package/dist/services/settings.service.d.ts +3 -2
  71. package/dist/services/settings.service.js +30 -10
  72. package/dist/services/task.service.d.ts +87 -3
  73. package/dist/services/task.service.js +351 -16
  74. package/dist/services/wsjf-health.service.d.ts +7 -1
  75. package/dist/services/wsjf-health.service.js +27 -0
  76. package/dist/services/wsjf.service.d.ts +19 -0
  77. package/dist/services/wsjf.service.js +33 -0
  78. package/dist/skills/tasks/blocked.md +2 -1
  79. package/dist/skills/tasks/decompose.md +30 -4
  80. package/dist/skills/tasks/loop-dag.md +3 -1
  81. package/dist/skills/tasks/loop.md +1 -1
  82. package/dist/slack/notifier.js +1 -0
  83. package/dist/slack/task-formatter.js +1 -0
  84. package/dist/types/task.d.ts +12 -17
  85. package/dist/types/wsjf.d.ts +1 -1
  86. package/dist/types/wsjf.js +2 -0
  87. package/dist/utils/parse-json-column.d.ts +25 -0
  88. package/dist/utils/parse-json-column.js +39 -0
  89. package/docs/API.md +23 -10
  90. package/docs/ARCHITECTURE.md +16 -14
  91. package/docs/CLI.md +11 -12
  92. package/docs/INTERFACES.md +1 -1
  93. package/docs/MCP.md +105 -4
  94. package/docs/SLACK.md +1 -0
  95. package/package.json +1 -1
  96. package/packages/wft-router/README.md +12 -0
  97. package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
  98. package/packages/wft-router/dist/bin/wft-router.js +18 -0
  99. package/packages/wft-router/dist/config/event-types.d.ts +1 -1
  100. package/packages/wft-router/dist/config/event-types.js +1 -0
  101. package/packages/wft-router/dist/config/triggers-schema.d.ts +9 -0
  102. package/packages/wft-router/dist/config/triggers-schema.js +10 -0
  103. package/packages/wft-router/dist/daemon.d.ts +36 -0
  104. package/packages/wft-router/dist/daemon.js +101 -2
  105. package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
  106. package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
  107. package/packages/wft-router/dist/index.d.ts +2 -2
  108. package/packages/wft-router/dist/index.js +1 -1
  109. package/packages/wft-router/dist/metrics.d.ts +64 -4
  110. package/packages/wft-router/dist/metrics.js +0 -0
  111. package/packages/wft-router/dist/sse/client.d.ts +19 -0
  112. package/packages/wft-router/dist/sse/client.js +57 -3
  113. package/packages/wft-router/dist/sse/index.d.ts +1 -1
  114. package/packages/wft-router/dist/sse/index.js +1 -1
package/CHANGELOG.md CHANGED
@@ -11,7 +11,60 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
- _No changes yet._
14
+ ## [v2.2.0] - 2026-06-11
15
+
16
+ ### Added
17
+ - **Guaranteed task sizing.** Every task now carries a server-derived job size
18
+ so the Configurable-Task-Models `resolve_model` size routing (shipped in
19
+ v2.1.0) engages on the live backlog, while each task stays honestly
20
+ *unscored* for WSJF ranking: a deterministic `minutesToTier` mapping,
21
+ `auto_size` / `boot_sweep` history triggers, a server-internal size-only
22
+ write path, a `decomp-*` submission-contract gate, auto-sizing of WSJF-less
23
+ creates, a minutes-vs-jobSize conflict gate, recompute on `update_task` when
24
+ `estimated_minutes` changes (manual sizes are never clobbered), an idempotent
25
+ **boot sweep** that backfills NULL job sizes on every server start, and a
26
+ `wsjf_health` auto-sized-pending finding.
27
+ - **`wft list --status all`** explicit sentinel and a `statusFilter` echo in
28
+ `--json` output (task #1006). The default `wft list` view already returns
29
+ every status — open, in_progress, blocked, done, and closed — and the
30
+ `--help` text now states this plainly so machine consumers don't read a
31
+ status transition (e.g. open → blocked) as a deleted task. `--status all`
32
+ is accepted as a self-documenting way to ask for "every status", and the
33
+ JSON envelope's `metadata.statusFilter` names the effective filter
34
+ (`all` or the requested status). The default view is unchanged.
35
+
36
+ ### Changed
37
+ - **Configurable Task Models hardening** (PR-#55 review follow-ups, #928–#933):
38
+ `resolve_model` parity hardening for missing-project and foreign/nonexistent
39
+ `task_id`; single-sourced `PIPELINE_ROLES` / `FAMILY_LADDER` /
40
+ `DEFAULT_MODEL_MAP` with a code-level `resolveAuto`; the `modelPolicyService`
41
+ is now wired once in `createApp` with a memoized global policy and a prepared
42
+ resolver lookup; the model-catalog wire shape, model-tool definitions, and
43
+ JSON-column parser are single-sourced.
44
+ - **wft-router**: real-event age gauge, start time, by-kind split, debug
45
+ pings, and stale-subscription resubscribe; plus an opt-in cold-start sweep
46
+ that dispatches pre-existing open backlogs so a freshly started router does
47
+ not miss work created while it was down.
48
+
49
+ ### Fixed
50
+ - **SSE**: close the stream on every server-initiated eviction and align the
51
+ buffer TTL with the connection age window, so long-lived clients no longer
52
+ silently go deaf at the eviction boundary (#1001).
53
+ - **Claims**: same-assignee renewal, a `task.claim_released` event, and TTL
54
+ visibility — long-running workers no longer lose their claim without a signal
55
+ (#1003).
56
+ - **Atomic block-with-dependency** via `update_task blocked_by`: a `blocked`
57
+ status without a blocking edge is rejected rather than becoming a dead end
58
+ (#1004).
59
+ - **Non-interactive `tasks create` / `tasks comment-add` default attribution to
60
+ the logged-in identity** (task #1007). Scripted runs no longer fail with
61
+ "Missing required field: created-by" (or `author`) when `--created-by` /
62
+ `--author` is omitted but credentials are present — the value defaults to the
63
+ credentials display name (email fallback), the same identity `tasks whoami`
64
+ reports. The original error is preserved only when no identity can be resolved
65
+ (no credentials file).
66
+ - **wft-router**: spell the NUL label-separator as a `\u0000` escape rather than
67
+ a raw byte.
15
68
 
16
69
  ## [v2.1.1] - 2026-06-09
17
70
 
@@ -65,8 +118,9 @@ _No changes yet._
65
118
  - **Test suite no longer launches the developer's real browser.**
66
119
  `setup.remote.test.ts` drove the device flow with `openBrowser: true`, so
67
120
  every `npm test` on a DISPLAY-set desktop spawned `xdg-open` at a mock
68
- server. `openBrowser()` now honors `WFT_NO_BROWSER`, set for every test via
69
- `vitest.setup.ts`.
121
+ server. `runDeviceLogin` now takes an injectable `opener` seam (defaulting to
122
+ the real `openBrowser`); tests inject a stub instead of relying on a global
123
+ env flag that, if leaked, would silently disable browser login for real users.
70
124
  - **Documentation counts drift.** README / `docs/INTERFACES.md` / `docs/MCP.md`
71
125
  had stale tool (27 vs 31), route (52/45 vs 59/52), and CLI command (42 vs 45)
72
126
  counts, plus a stale "model tools are stdio-only" claim from before remote
package/README.md CHANGED
@@ -658,7 +658,8 @@ Wood Fired Tasks streams real-time task and project change notifications via Ser
658
658
  | task.updated | Task fields modified |
659
659
  | task.deleted | Task deleted |
660
660
  | task.status_changed | Task status transition |
661
- | task.claimed | Task claimed by agent |
661
+ | task.claimed | Task claimed by agent (also emitted on a same-assignee claim renewal) |
662
+ | task.claim_released | Stale claim auto-released by the TTL sweep (carries `previous_assignee`, `expired_claimed_at`, `released_at`) |
662
663
  | project.created | New project created |
663
664
  | project.updated | Project modified |
664
665
  | project.deleted | Project deleted |
package/SECURITY.md CHANGED
@@ -14,11 +14,11 @@ security updates. Older tags are provided as-is.
14
14
  | Version | Supported |
15
15
  | ----------------- | ------------------ |
16
16
  | `main` (HEAD) | :white_check_mark: |
17
- | `v2.1.1` (latest) | :white_check_mark: |
17
+ | `v2.2.0` (latest) | :white_check_mark: |
18
18
  | `v1.0` – `v2.0.6` | :x: |
19
19
 
20
20
  "Latest" tracks whichever tag is most recent on GitHub; at the time of
21
- writing that is `v2.1.1`. If you are reading this on an older checkout,
21
+ writing that is `v2.2.0`. If you are reading this on an older checkout,
22
22
  verify the current latest release via
23
23
  `git tag --sort=-creatordate | head -1` or the GitHub Releases page.
24
24
 
@@ -94,6 +94,8 @@ export declare function parseTaskResponse(payload: unknown, endpoint: string): {
94
94
  verifier_request_id?: string | undefined;
95
95
  verified_at?: string | undefined;
96
96
  } | null;
97
+ claim_ttl_minutes?: number | undefined;
98
+ claim_remaining_seconds?: number | undefined;
97
99
  };
98
100
  /** Convenience: validate a single project response body. */
99
101
  export declare function parseProjectResponse(payload: unknown, endpoint: string): {
@@ -186,6 +188,8 @@ export declare function parseTaskListResponse(payload: unknown, endpoint: string
186
188
  verifier_request_id?: string | undefined;
187
189
  verified_at?: string | undefined;
188
190
  } | null;
191
+ claim_ttl_minutes?: number | undefined;
192
+ claim_remaining_seconds?: number | undefined;
189
193
  }[];
190
194
  total: number;
191
195
  limit: number;
@@ -1,5 +1,21 @@
1
1
  import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
2
2
  import { z } from 'zod';
3
+ /**
4
+ * Configurable Task Models (Task 13) — GET /api/v1/models
5
+ *
6
+ * Exposes the runtime-discovered Claude model catalog (task #917's
7
+ * `model-catalog.service`) over REST so the remote MCP proxy / dashboard /
8
+ * `set-models` interview can enumerate the available models. The 200 body is
9
+ * `{ models, stale }` — identical to the service's `ModelCatalog` and the
10
+ * stdio MCP `list_models` tool's structured output.
11
+ *
12
+ * `stale: true` signals the catalog was served from the static fallback (no
13
+ * ANTHROPIC_API_KEY, non-OK Models API response, or a network error). The
14
+ * service NEVER throws from `list()`, so this route always returns 200.
15
+ *
16
+ * Auth: inherits the standard `/api/v1` auth chain (the parent plugin is
17
+ * mounted inside the `/api/v1` scope that wires `authPlugin`). No custom guard.
18
+ */
3
19
  /** The `{ models, stale }` envelope returned by GET /models. */
4
20
  export declare const ModelCatalogResponseSchema: z.ZodObject<{
5
21
  models: z.ZodArray<z.ZodObject<{
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ModelCatalogEntrySchema } from '../../../schemas/model-catalog.schema.js';
2
3
  /**
3
4
  * Configurable Task Models (Task 13) — GET /api/v1/models
4
5
  *
@@ -15,13 +16,6 @@ import { z } from 'zod';
15
16
  * Auth: inherits the standard `/api/v1` auth chain (the parent plugin is
16
17
  * mounted inside the `/api/v1` scope that wires `authPlugin`). No custom guard.
17
18
  */
18
- /** One discovered model, mirroring `ModelCatalogEntry` from the catalog service. */
19
- const ModelCatalogEntrySchema = z.object({
20
- id: z.string(),
21
- display_name: z.string(),
22
- family: z.string(),
23
- created_at: z.string(),
24
- });
25
19
  /** The `{ models, stale }` envelope returned by GET /models. */
26
20
  export const ModelCatalogResponseSchema = z.object({
27
21
  models: z.array(ModelCatalogEntrySchema),
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ErrorResponseSchema } from '../tasks/schemas.js';
3
+ import { PipelineRoleSchema } from '../../../schemas/model-policy.schema.js';
3
4
  /**
4
5
  * Configurable Task Models (Task #926) — GET /api/v1/projects/:id/resolve-model
5
6
  *
@@ -21,10 +22,15 @@ import { ErrorResponseSchema } from '../tasks/schemas.js';
21
22
  * wires in-process (project policy ?? global default, per-slot merge, jobSize→
22
23
  * category routing when `task_id` is supplied).
23
24
  *
24
- * Project-existence is enforced explicitly so a missing project yields a 404
25
- * ProblemDetails (mirrors the sibling `/:id/topology` and `/:id/dependency-graph`
26
- * routes). Without the guard an unknown project would resolve to the global
27
- * default (or null) silently rather than signalling the bad id.
25
+ * Project-existence is enforced by the resolver itself (task #928): a missing
26
+ * project throws NotFoundError → 404 ProblemDetails (mirrors the sibling
27
+ * `/:id/topology` and `/:id/dependency-graph` routes), so an unknown project
28
+ * never resolves to the global default (or null) silently. The route carries
29
+ * no separate pre-fetch guard (task #931): the resolver's single project
30
+ * fetch doubles as the 404 check. The resolver also validates `task_id`: a
31
+ * nonexistent one throws NotFoundError (→ 404) and one belonging to a
32
+ * different project throws ValidationError (→ 400), so size-routing can never
33
+ * silently use a foreign task's jobSize.
28
34
  *
29
35
  * Auth: inherits the standard projects-route auth chain (the parent
30
36
  * `projectRoutes` plugin is mounted inside the `/api/v1` scope that wires
@@ -43,18 +49,21 @@ const resolveModelRoutes = async (fastify) => {
43
49
  'MCP tool output. Read-only.',
44
50
  params: z.object({ id: z.coerce.number().int().positive() }),
45
51
  querystring: z.object({
46
- role: z.enum(['execution', 'validation', 'planning']),
52
+ role: PipelineRoleSchema,
47
53
  task_id: z.coerce.number().int().positive().optional(),
48
54
  }),
49
55
  response: {
50
56
  200: ResolveModelResponseSchema,
57
+ 400: ErrorResponseSchema,
51
58
  404: ErrorResponseSchema,
52
59
  },
53
60
  },
54
61
  }, async (request, reply) => {
55
- // Existence guard → 404 on a missing project. getProject throws
56
- // NotFoundError, mapped to the 404 ProblemDetails by the error handler.
57
- fastify.projectService.getProject(request.params.id);
62
+ // Existence guard → 404 on a missing project: since task #928 the
63
+ // resolver itself throws NotFoundError (mapped to the 404
64
+ // ProblemDetails by the error handler), so the former
65
+ // `projectService.getProject` pre-fetch here was a redundant second
66
+ // fetch + full inflation of the same row (task #931 — one shared fetch).
58
67
  const resolved = fastify.modelPolicyService.resolveModel(request.params.id, request.query.role, request.query.task_id);
59
68
  // `resolved` is `{ model } | { model: 'auto' } | null` — sent verbatim so
60
69
  // the remote MCP tool is transport-indistinguishable from the stdio one.
@@ -104,6 +104,10 @@ const HealthFindingSchema = z.object({
104
104
  'stale-time-criticality',
105
105
  'high-fallback-ratio',
106
106
  'score-churn',
107
+ 'auto-sized-pending',
108
+ // Task #1004: emitted by check_health (edge-less blocked lint); part of
109
+ // the shared HealthCheckId union, never produced by the WSJF linter.
110
+ 'blocked-without-edge',
107
111
  ]),
108
112
  severity: z.enum(['info', 'warning', 'critical']),
109
113
  message: z.string(),
@@ -48,10 +48,14 @@ const modelPolicyRoutes = async (fastify) => {
48
48
  // the clear path persists SQL NULL rather than tripping the service's
49
49
  // non-null Zod parse. A non-null body has already passed the
50
50
  // `ModelPolicyNullableSchema` body validator (invalid → 400 upstream).
51
- fastify.settingsService.setModelPolicyDefault(request.body ?? null);
52
- // Echo the persisted policy back so callers get a post-write confirmation
53
- // (and round-trip the canonical stored shape).
54
- return reply.send(fastify.settingsService.getModelPolicyDefault());
51
+ const policy = request.body ?? null;
52
+ fastify.settingsService.setModelPolicyDefault(policy);
53
+ // Echo the validated body back. `request.body` has already been parsed
54
+ // through `ModelPolicyNullableSchema` by the route's body validator —
55
+ // the SAME canonical shape the service just persisted — so re-reading +
56
+ // re-validating the singleton row here was a redundant round-trip
57
+ // (task #931). A write failure throws before this line.
58
+ return reply.send(policy);
55
59
  });
56
60
  };
57
61
  export default modelPolicyRoutes;
@@ -31,6 +31,8 @@ export declare const TaskResponseSchema: z.ZodObject<{
31
31
  updated_at: z.ZodString;
32
32
  version: z.ZodNumber;
33
33
  claimed_at: z.ZodNullable<z.ZodString>;
34
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
35
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
34
36
  tags: z.ZodArray<z.ZodString>;
35
37
  acceptance_criteria: z.ZodNullable<z.ZodString>;
36
38
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -88,6 +90,8 @@ export declare const TaskListResponseSchema: z.ZodArray<z.ZodObject<{
88
90
  updated_at: z.ZodString;
89
91
  version: z.ZodNumber;
90
92
  claimed_at: z.ZodNullable<z.ZodString>;
93
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
94
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
91
95
  tags: z.ZodArray<z.ZodString>;
92
96
  acceptance_criteria: z.ZodNullable<z.ZodString>;
93
97
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -145,6 +149,8 @@ export declare const TaskListPaginatedResponseSchema: z.ZodObject<{
145
149
  updated_at: z.ZodString;
146
150
  version: z.ZodNumber;
147
151
  claimed_at: z.ZodNullable<z.ZodString>;
152
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
153
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
148
154
  tags: z.ZodArray<z.ZodString>;
149
155
  acceptance_criteria: z.ZodNullable<z.ZodString>;
150
156
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -210,6 +216,8 @@ export declare const ClaimResponseSchema: z.ZodObject<{
210
216
  updated_at: z.ZodString;
211
217
  version: z.ZodNumber;
212
218
  claimed_at: z.ZodNullable<z.ZodString>;
219
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
220
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
213
221
  tags: z.ZodArray<z.ZodString>;
214
222
  acceptance_criteria: z.ZodNullable<z.ZodString>;
215
223
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -21,6 +21,16 @@ export const TaskResponseSchema = z.object({
21
21
  updated_at: z.string(),
22
22
  version: z.number(),
23
23
  claimed_at: z.string().nullable(),
24
+ /**
25
+ * Task #1003: claim-TTL visibility. Computed at read time by
26
+ * `TaskService.getTask` for tasks holding an active claim (in_progress +
27
+ * assignee + claimed_at); absent otherwise. `claim_remaining_seconds` is
28
+ * the floor-clamped time until the ClaimReleaseService sweep may
29
+ * auto-release the claim — renew with a same-assignee `claim` call
30
+ * before it reaches 0.
31
+ */
32
+ claim_ttl_minutes: z.number().int().positive().optional(),
33
+ claim_remaining_seconds: z.number().int().nonnegative().optional(),
24
34
  tags: z.array(z.string()),
25
35
  /**
26
36
  * Wave 1.3 (task #311): optional free-form acceptance criteria (markdown).
@@ -9,7 +9,7 @@ import { TopologyService } from '../services/topology.service.js';
9
9
  import { CommentService } from '../services/comment.service.js';
10
10
  import { SettingsService } from '../services/settings.service.js';
11
11
  import { ModelCatalogService } from '../services/model-catalog.service.js';
12
- import { type ModelPolicyService } from '../services/model-policy.service.js';
12
+ import type { ModelPolicyService } from '../services/model-policy.service.js';
13
13
  import { SSEManager } from '../events/sse-manager.js';
14
14
  import { IdempotencyService } from '../services/idempotency.service.js';
15
15
  /**
@@ -10,9 +10,6 @@ import fastifyFormbody from '@fastify/formbody';
10
10
  import { createApp } from '../index.js';
11
11
  import { config } from '../config/env.js';
12
12
  import { SESSION_LIFETIME_SECONDS } from '../web/session-constants.js';
13
- import { createModelPolicyService, } from '../services/model-policy.service.js';
14
- import { ProjectRepository } from '../repositories/project.repository.js';
15
- import { TaskRepository } from '../repositories/task.repository.js';
16
13
  import { SSEManager } from '../events/sse-manager.js';
17
14
  import { IdempotencyService } from '../services/idempotency.service.js';
18
15
  import { ClaimReleaseService } from '../services/claim-release.service.js';
@@ -109,22 +106,11 @@ export async function createServer(options) {
109
106
  // back the /settings/model-policy and /models routes respectively.
110
107
  server.decorate('settingsService', app.settingsService);
111
108
  server.decorate('modelCatalogService', app.modelCatalogService);
112
- // Configurable Task Models (Task #926): construct the model-policy resolver
113
- // behind GET /projects/:id/resolve-model. Its three lookups are injected over
114
- // the SAME `app.db` handle every other service shares (db-backed repos are
115
- // stateless prepared-statement holders), mirroring how `src/mcp/index.ts`
116
- // wires the resolver behind the stdio `resolve_model` tool:
117
- // - getProjectPolicy ← a project's parsed `model_policy` column.
118
- // - getGlobalPolicy ← the SAME settings service backing get/set defaults.
119
- // - getJobSize ← a task's WSJF jobSize Fibonacci tier.
120
- const modelPolicyProjectRepo = new ProjectRepository(app.db);
121
- const modelPolicyTaskRepo = new TaskRepository(app.db);
122
- const modelPolicyService = createModelPolicyService({
123
- getProjectPolicy: (projectId) => modelPolicyProjectRepo.findById(projectId)?.model_policy ?? null,
124
- getGlobalPolicy: () => app.settingsService.getModelPolicyDefault(),
125
- getJobSize: (taskId) => modelPolicyTaskRepo.findById(taskId)?.wsjf_job_size ?? null,
126
- });
127
- server.decorate('modelPolicyService', modelPolicyService);
109
+ // Configurable Task Models (Task #926 / #931): the model-policy resolver
110
+ // behind GET /projects/:id/resolve-model. Constructed ONCE in `createApp`
111
+ // (same instance the stdio MCP server wires behind its `resolve_model`
112
+ // tool) see src/index.ts for the dep wiring.
113
+ server.decorate('modelPolicyService', app.modelPolicyService);
128
114
  server.decorate('db', app.db);
129
115
  // Task #357: expose OIDC boot state to /health/detailed so a degraded
130
116
  // discovery is a queryable signal, not just a one-time boot log line.
@@ -170,6 +156,7 @@ export async function createServer(options) {
170
156
  eventBus.subscribe('task.deleted', (event) => sseManager.broadcast(event)),
171
157
  eventBus.subscribe('task.status_changed', (event) => sseManager.broadcast(event)),
172
158
  eventBus.subscribe('task.claimed', (event) => sseManager.broadcast(event)),
159
+ eventBus.subscribe('task.claim_released', (event) => sseManager.broadcast(event)),
173
160
  eventBus.subscribe('project.created', (event) => sseManager.broadcast(event)),
174
161
  eventBus.subscribe('project.updated', (event) => sseManager.broadcast(event)),
175
162
  eventBus.subscribe('project.deleted', (event) => sseManager.broadcast(event)),
@@ -1,5 +1,6 @@
1
1
  import type { TaskResponse, CreateTaskInput, UpdateTaskInput, TaskFilters, ApiErrorResponse, ProjectResponse, CreateProjectInput, UpdateProjectInput, DependencyResponse, DependencyListResponse, CreateDependencyInput, CommentResponse, CreateCommentInput, HealthResponse, PaginatedResponse, PaginationParams } from './types.js';
2
2
  import type { ModelPolicyNullable } from '../../schemas/model-policy.schema.js';
3
+ import type { ModelCatalogEntry } from '../../schemas/model-catalog.schema.js';
3
4
  /**
4
5
  * Custom error class for API client errors.
5
6
  * Includes HTTP status code, parsed API error response, and optional request ID for tracing.
@@ -115,13 +116,12 @@ export declare function getSubtasksPaginated(parentTaskId: number, pagination?:
115
116
  * Sets assignee and transitions status to in_progress in a single operation.
116
117
  */
117
118
  export declare function claimTask(taskId: number, assignee: string, idempotencyKey?: string): Promise<TaskResponse>;
118
- /** One discovered catalog model, mirroring the server's `GET /models` row. */
119
- export interface ModelCatalogEntry {
120
- id: string;
121
- display_name: string;
122
- family: string;
123
- created_at: string;
124
- }
119
+ /**
120
+ * One discovered catalog model — the server's `GET /models` row (task #930:
121
+ * the shape is declared once in `src/schemas/model-catalog.schema.ts` and
122
+ * re-exported here for CLI consumers).
123
+ */
124
+ export type { ModelCatalogEntry };
125
125
  /** The `{ models, stale }` envelope returned by `GET /models`. */
126
126
  export interface ModelCatalogResponse {
127
127
  models: ModelCatalogEntry[];
@@ -53,6 +53,12 @@ export interface UpdateTaskInput {
53
53
  tags?: string[];
54
54
  /** Wave 1.3 (#311): patch acceptance criteria; null clears, string sets. */
55
55
  acceptance_criteria?: string | null;
56
+ /**
57
+ * Task #1004: atomic block-with-dependency. Only valid alongside
58
+ * `status: 'blocked'` — the server adds the blocking edge(s) and sets the
59
+ * status in one transaction (an invalid edge rolls back the whole call).
60
+ */
61
+ blocked_by?: number[];
56
62
  }
57
63
  export interface TaskFilters {
58
64
  project_id?: number;
@@ -69,17 +69,6 @@ function isSafeBrowserUrl(url) {
69
69
  return true;
70
70
  }
71
71
  export function openBrowser(url) {
72
- // Test/automation guard. When WFT_NO_BROWSER is set we never spawn a real
73
- // browser — the caller still prints the URL as its fallback. The linux
74
- // `!DISPLAY` branch below already covers headless CI, but it does NOT cover a
75
- // developer DESKTOP running the suite (DISPLAY is set there): an in-process
76
- // device-flow/login test (e.g. setup.remote.test.ts's "real device flow"
77
- // case) would otherwise spawn the developer's actual browser at the mock
78
- // server's verification_uri and 404. vitest.setup.ts sets this for every test
79
- // file; browser-open.test.ts opts out locally to exercise the real spawn path.
80
- if (process.env['WFT_NO_BROWSER']) {
81
- return false;
82
- }
83
72
  // WR-03 (Phase 30 review) — validate the URL BEFORE selecting the
84
73
  // platform spawn args. A malformed/suspicious URL → false so the caller
85
74
  // falls back to printing the URL for the user to paste manually.
@@ -21,4 +21,23 @@ export declare function getCredentialsPath(): string;
21
21
  export declare function readCredentials(filePath?: string): Credentials | null;
22
22
  export declare function writeCredentials(creds: Credentials, filePath?: string): void;
23
23
  export declare function deleteCredentials(filePath?: string): boolean;
24
+ /**
25
+ * Resolve the logged-in identity's display name for attribution defaults
26
+ * (e.g. `tasks create --created-by`, `tasks comment-add --author`).
27
+ *
28
+ * Source of truth is the on-disk credentials file (`readCredentials`) — the
29
+ * SAME file `tasks whoami` reads. We prefer `display_name`; if it is empty
30
+ * (service accounts may have a blank display name) we fall back to `email`.
31
+ *
32
+ * Returns `null` when no identity can be resolved (no credentials file, or
33
+ * a credentials file with neither a display name nor an email). Callers then
34
+ * fall back to their existing "missing required field" behaviour so scripted
35
+ * runs without any identity still surface an actionable error.
36
+ *
37
+ * NOTE: this is intentionally a thin, synchronous read of the local file —
38
+ * it does NOT hit the network. The credentials file is written by
39
+ * `tasks login` from the server's identity envelope, so the cached
40
+ * display_name/email already reflect the authenticated user.
41
+ */
42
+ export declare function resolveIdentityName(filePath?: string): string | null;
24
43
  export declare function resolveAuth(): Promise<AuthSource>;
@@ -154,6 +154,36 @@ export function deleteCredentials(filePath = getCredentialsPath()) {
154
154
  unlinkSync(filePath);
155
155
  return true;
156
156
  }
157
+ /**
158
+ * Resolve the logged-in identity's display name for attribution defaults
159
+ * (e.g. `tasks create --created-by`, `tasks comment-add --author`).
160
+ *
161
+ * Source of truth is the on-disk credentials file (`readCredentials`) — the
162
+ * SAME file `tasks whoami` reads. We prefer `display_name`; if it is empty
163
+ * (service accounts may have a blank display name) we fall back to `email`.
164
+ *
165
+ * Returns `null` when no identity can be resolved (no credentials file, or
166
+ * a credentials file with neither a display name nor an email). Callers then
167
+ * fall back to their existing "missing required field" behaviour so scripted
168
+ * runs without any identity still surface an actionable error.
169
+ *
170
+ * NOTE: this is intentionally a thin, synchronous read of the local file —
171
+ * it does NOT hit the network. The credentials file is written by
172
+ * `tasks login` from the server's identity envelope, so the cached
173
+ * display_name/email already reflect the authenticated user.
174
+ */
175
+ export function resolveIdentityName(filePath = getCredentialsPath()) {
176
+ const creds = readCredentials(filePath);
177
+ if (creds === null)
178
+ return null;
179
+ const { display_name, email } = creds.active;
180
+ const name = display_name.trim();
181
+ if (name.length > 0)
182
+ return name;
183
+ if (email && email.trim().length > 0)
184
+ return email.trim();
185
+ return null;
186
+ }
157
187
  export async function resolveAuth() {
158
188
  // 1. --token flag wins unconditionally.
159
189
  if (currentTokenOverride !== null) {
@@ -4,6 +4,7 @@ import { colorError, colorSuccess } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
6
  import { promptForMissing } from '../prompts/interactive.js';
7
+ import { resolveIdentityName } from '../auth/credentials.js';
7
8
  export const commentAddCommand = new Command('comment-add')
8
9
  .description('Add a comment to a task')
9
10
  .argument('<id>', 'Task ID')
@@ -22,8 +23,14 @@ export const commentAddCommand = new Command('comment-add')
22
23
  const program = commentAddCommand.parent;
23
24
  const globalOpts = program?.optsWithGlobals() || {};
24
25
  const isJsonMode = globalOpts['json'] || false;
26
+ // When --author is omitted, default to the logged-in identity
27
+ // (credentials PAT user) so scripted, non-interactive runs no longer
28
+ // have to hardcode an author (papercut #1007). promptForMissing then
29
+ // short-circuits — interactive prompting / the "Missing required field:
30
+ // author" error remain ONLY when no identity can be resolved.
31
+ const authorOverride = options.author ?? resolveIdentityName() ?? undefined;
25
32
  // Prompt for missing required fields
26
- const author = await promptForMissing('author', options.author);
33
+ const author = await promptForMissing('author', authorOverride);
27
34
  const content = await promptForMissing('content', options.content);
28
35
  // Add comment via API
29
36
  const comment = await addComment(id, {
@@ -4,6 +4,7 @@ import { formatTaskDetail, colorSuccess, colorError } from '../output/formatters
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
6
  import { promptForMissing } from '../prompts/interactive.js';
7
+ import { resolveIdentityName } from '../auth/credentials.js';
7
8
  const VALID_PRIORITIES = ['low', 'medium', 'high', 'urgent'];
8
9
  export const createCommand = new Command('create')
9
10
  .description('Create a new task')
@@ -27,10 +28,17 @@ export const createCommand = new Command('create')
27
28
  const program = createCommand.parent;
28
29
  const globalOpts = program?.optsWithGlobals() || {};
29
30
  const isJsonMode = globalOpts['json'] || false;
31
+ // When --created-by is omitted, default to the logged-in identity
32
+ // (credentials PAT user) so scripted, non-interactive runs no longer
33
+ // have to hardcode an email (papercut #1007). promptForMissing then
34
+ // short-circuits (value is defined) — interactive prompting and the
35
+ // "Missing required field: created-by" error remain ONLY when no
36
+ // identity can be resolved (no credentials).
37
+ const createdByOverride = options.createdBy ?? resolveIdentityName() ?? undefined;
30
38
  // Prompt for missing required fields (interactive mode only)
31
39
  const title = await promptForMissing('title', options.title);
32
40
  const projectStr = await promptForMissing('project', options.project);
33
- const createdBy = await promptForMissing('created-by', options.createdBy);
41
+ const createdBy = await promptForMissing('created-by', createdByOverride);
34
42
  // Parse and validate project ID
35
43
  const project = typeof projectStr === 'number' ? projectStr : parseInt(projectStr, 10);
36
44
  if (isNaN(project)) {
@@ -4,11 +4,22 @@ import { formatTaskTable, colorError, colorWarn, colorInfo } from '../output/for
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
6
  const VALID_STATUSES = ['open', 'in_progress', 'done', 'closed', 'blocked'];
7
+ // `all` is a CLI-only sentinel meaning "no server-side status filter" — it is
8
+ // NOT a real task status, so it is accepted by the CLI but never forwarded as
9
+ // `?status=` to the API. Listing every status (including blocked + closed) is
10
+ // exactly what omitting the filter already does server-side.
11
+ const STATUS_ALL = 'all';
7
12
  const MAX_LIMIT = 500;
8
13
  export const listCommand = new Command('list')
9
14
  .description('List tasks with optional filters')
10
15
  .option('-p, --project <id>', 'Filter by project ID', parseInt)
11
- .option('-s, --status <status>', 'Filter by status')
16
+ .option('-s, --status <status>',
17
+ // Default (no --status): the server applies NO status filter, so every
18
+ // status is returned — open, in_progress, blocked, done, AND closed. None
19
+ // are hidden. Pass a single status to narrow, or the explicit sentinel
20
+ // `all` to make "every status (incl. blocked + closed)" self-documenting
21
+ // in scripts. Valid values: open | in_progress | blocked | done | closed | all.
22
+ `Filter by status (default: all statuses shown, incl. blocked + closed). Use 'all' to be explicit.`)
12
23
  .option('-a, --assignee <name>', 'Filter by assignee')
13
24
  .option('--search <query>', 'Search tasks by title/description')
14
25
  .option('--tags <tags>', 'Filter by tags (comma-separated)')
@@ -18,9 +29,12 @@ export const listCommand = new Command('list')
18
29
  .option('--offset <n>', 'Zero-based offset for pagination (default 0)', (v) => parseInt(v, 10))
19
30
  .action(async (options) => {
20
31
  try {
21
- // Validate status if provided
22
- if (options.status && !VALID_STATUSES.includes(options.status)) {
23
- console.error(colorError(`Invalid status: ${options.status}. Valid options: ${VALID_STATUSES.join(', ')}`));
32
+ // Validate status if provided. `all` is the explicit "every status"
33
+ // sentinel and is accepted alongside the real statuses.
34
+ if (options.status &&
35
+ options.status !== STATUS_ALL &&
36
+ !VALID_STATUSES.includes(options.status)) {
37
+ console.error(colorError(`Invalid status: ${options.status}. Valid options: ${[...VALID_STATUSES, STATUS_ALL].join(', ')}`));
24
38
  process.exitCode = 1;
25
39
  return;
26
40
  }
@@ -50,7 +64,9 @@ export const listCommand = new Command('list')
50
64
  if (options.project !== undefined) {
51
65
  filters.project_id = options.project;
52
66
  }
53
- if (options.status) {
67
+ // `all` means "no server-side status filter" — every status (incl.
68
+ // blocked + closed) is returned. Any other value narrows to that status.
69
+ if (options.status && options.status !== STATUS_ALL) {
54
70
  filters.status = options.status;
55
71
  }
56
72
  if (options.assignee) {
@@ -81,10 +97,17 @@ export const listCommand = new Command('list')
81
97
  const program = listCommand.parent;
82
98
  const globalOpts = program?.optsWithGlobals() || {};
83
99
  const isJsonMode = globalOpts['json'] || false;
100
+ // Effective status filter, echoed so machine consumers can detect
101
+ // exactly what they asked for. `all` is reported when no status was
102
+ // requested (or `--status all` was passed) — i.e. blocked + closed are
103
+ // included rather than silently filtered out.
104
+ const statusFilter = options.status && options.status !== STATUS_ALL ? options.status : STATUS_ALL;
84
105
  // Display results
85
106
  if (isJsonMode) {
86
- // JSON mode: output envelope to stdout
87
- jsonOutput(tasks, { count: tasks.length });
107
+ // JSON mode: output envelope to stdout. `statusFilter` names the
108
+ // effective filter so scripts never mistake a status transition
109
+ // (e.g. open → blocked) for a deleted task.
110
+ jsonOutput(tasks, { count: tasks.length, statusFilter });
88
111
  }
89
112
  else {
90
113
  // Terminal mode: formatted output
@@ -11,6 +11,9 @@ import type { PromptIO } from '../util/prompt.js';
11
11
  * - `hostname` — local hostname, surfaced to the server for PAT auto-naming.
12
12
  * - `tokenName` — optional advisory PAT name (`--token-name`).
13
13
  * - `openBrowser` — when `true`, best-effort auto-open the verification URL.
14
+ * - `opener` — injectable browser-launch seam. Defaults to the real
15
+ * {@link openBrowser}. Tests pass a stub so the suite never
16
+ * spawns a real browser; production never overrides it.
14
17
  * - `isJson` — when `true`, emit newline-separated JSON envelopes on stdout
15
18
  * instead of human-friendly text on stderr.
16
19
  */
@@ -20,6 +23,13 @@ export interface RunDeviceLoginArgs {
20
23
  hostname: string;
21
24
  tokenName?: string;
22
25
  openBrowser: boolean;
26
+ /**
27
+ * Injectable browser opener (defaults to {@link openBrowser}). This is the
28
+ * seam that replaces the former no-browser env bandaid: tests inject a
29
+ * stub instead of relying on a global env flag that, if leaked, would
30
+ * silently disable browser login for real users.
31
+ */
32
+ opener?: (url: string) => boolean;
23
33
  isJson: boolean;
24
34
  }
25
35
  /** Outcome of {@link runDeviceLogin}. `ok: false` means the flow already emitted