wood-fired-tasks 2.1.0 → 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 (117) hide show
  1. package/CHANGELOG.md +75 -4
  2. package/README.md +3 -2
  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/self-update.d.ts +16 -3
  28. package/dist/cli/commands/self-update.js +21 -0
  29. package/dist/cli/commands/settings-set-models.js +6 -19
  30. package/dist/cli/commands/setup.d.ts +8 -0
  31. package/dist/cli/commands/setup.js +1 -0
  32. package/dist/cli/commands/update.js +20 -0
  33. package/dist/events/event-bus.d.ts +2 -1
  34. package/dist/events/event-bus.js +1 -0
  35. package/dist/events/sse-manager.d.ts +30 -1
  36. package/dist/events/sse-manager.js +100 -18
  37. package/dist/events/types.d.ts +19 -2
  38. package/dist/events/types.js +1 -0
  39. package/dist/index.d.ts +10 -0
  40. package/dist/index.js +36 -1
  41. package/dist/mcp/index.js +5 -24
  42. package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
  43. package/dist/mcp/lib/model-tool-definitions.js +112 -0
  44. package/dist/mcp/remote/register-tools.js +14 -73
  45. package/dist/mcp/remote/rest-client.d.ts +4 -10
  46. package/dist/mcp/resources/events.js +2 -1
  47. package/dist/mcp/tools/health-tools.d.ts +14 -0
  48. package/dist/mcp/tools/health-tools.js +52 -1
  49. package/dist/mcp/tools/model-tools.d.ts +4 -3
  50. package/dist/mcp/tools/model-tools.js +13 -82
  51. package/dist/mcp/tools/task-tools.js +5 -1
  52. package/dist/repositories/interfaces.d.ts +39 -0
  53. package/dist/repositories/project-charter-history.repository.js +2 -12
  54. package/dist/repositories/project.repository.js +16 -48
  55. package/dist/repositories/task.repository.d.ts +48 -0
  56. package/dist/repositories/task.repository.js +90 -18
  57. package/dist/repositories/wsjf-history.repository.js +6 -16
  58. package/dist/schemas/model-catalog.schema.d.ts +22 -0
  59. package/dist/schemas/model-catalog.schema.js +21 -0
  60. package/dist/schemas/model-policy.schema.d.ts +14 -0
  61. package/dist/schemas/model-policy.schema.js +11 -0
  62. package/dist/schemas/task.schema.d.ts +13 -1
  63. package/dist/schemas/task.schema.js +11 -0
  64. package/dist/services/claim-release.service.d.ts +11 -0
  65. package/dist/services/claim-release.service.js +31 -2
  66. package/dist/services/job-size-backfill.d.ts +43 -0
  67. package/dist/services/job-size-backfill.js +81 -0
  68. package/dist/services/model-catalog.service.d.ts +28 -8
  69. package/dist/services/model-catalog.service.js +16 -4
  70. package/dist/services/model-policy.service.d.ts +87 -9
  71. package/dist/services/model-policy.service.js +96 -4
  72. package/dist/services/settings.service.d.ts +3 -2
  73. package/dist/services/settings.service.js +30 -10
  74. package/dist/services/task.service.d.ts +87 -3
  75. package/dist/services/task.service.js +351 -16
  76. package/dist/services/wsjf-health.service.d.ts +7 -1
  77. package/dist/services/wsjf-health.service.js +27 -0
  78. package/dist/services/wsjf.service.d.ts +19 -0
  79. package/dist/services/wsjf.service.js +33 -0
  80. package/dist/skills/tasks/blocked.md +2 -1
  81. package/dist/skills/tasks/decompose.md +30 -4
  82. package/dist/skills/tasks/loop-dag.md +3 -1
  83. package/dist/skills/tasks/loop.md +1 -1
  84. package/dist/slack/notifier.js +1 -0
  85. package/dist/slack/task-formatter.js +1 -0
  86. package/dist/types/task.d.ts +12 -17
  87. package/dist/types/wsjf.d.ts +1 -1
  88. package/dist/types/wsjf.js +2 -0
  89. package/dist/utils/parse-json-column.d.ts +25 -0
  90. package/dist/utils/parse-json-column.js +39 -0
  91. package/docs/API.md +23 -10
  92. package/docs/ARCHITECTURE.md +16 -14
  93. package/docs/CLI.md +11 -12
  94. package/docs/INTERFACES.md +1 -1
  95. package/docs/MCP.md +105 -4
  96. package/docs/SETUP.md +7 -2
  97. package/docs/SLACK.md +1 -0
  98. package/package.json +1 -1
  99. package/packages/wft-router/README.md +12 -0
  100. package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
  101. package/packages/wft-router/dist/bin/wft-router.js +18 -0
  102. package/packages/wft-router/dist/config/event-types.d.ts +1 -1
  103. package/packages/wft-router/dist/config/event-types.js +1 -0
  104. package/packages/wft-router/dist/config/triggers-schema.d.ts +9 -0
  105. package/packages/wft-router/dist/config/triggers-schema.js +10 -0
  106. package/packages/wft-router/dist/daemon.d.ts +36 -0
  107. package/packages/wft-router/dist/daemon.js +101 -2
  108. package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
  109. package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
  110. package/packages/wft-router/dist/index.d.ts +2 -2
  111. package/packages/wft-router/dist/index.js +1 -1
  112. package/packages/wft-router/dist/metrics.d.ts +64 -4
  113. package/packages/wft-router/dist/metrics.js +0 -0
  114. package/packages/wft-router/dist/sse/client.d.ts +19 -0
  115. package/packages/wft-router/dist/sse/client.js +57 -3
  116. package/packages/wft-router/dist/sse/index.d.ts +1 -1
  117. package/packages/wft-router/dist/sse/index.js +1 -1
@@ -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
@@ -48,6 +48,7 @@ function emitJsonEvent(event) {
48
48
  */
49
49
  export async function runDeviceLogin(args) {
50
50
  const { baseUrl, clientId, hostname, tokenName, openBrowser: shouldOpenBrowser, isJson } = args;
51
+ const opener = args.opener ?? openBrowser;
51
52
  // 2. Request a device_code from the server.
52
53
  let codeResponse;
53
54
  try {
@@ -99,7 +100,7 @@ export async function runDeviceLogin(args) {
99
100
  }
100
101
  // 4. Best-effort browser launch (skipped if !openBrowser).
101
102
  if (shouldOpenBrowser) {
102
- const opened = openBrowser(verification_uri_complete);
103
+ const opened = opener(verification_uri_complete);
103
104
  if (!isJson) {
104
105
  if (opened) {
105
106
  process.stderr.write('(Opening browser...)\n');
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { type ModelPolicy } from '../../schemas/model-policy.schema.js';
2
+ import { type ModelPolicy, type PipelineRole } from '../../schemas/model-policy.schema.js';
3
3
  /**
4
4
  * Configurable Task Models (Task 12) — CLI surface.
5
5
  *
@@ -14,9 +14,13 @@ import { type ModelPolicy } from '../../schemas/model-policy.schema.js';
14
14
  * `settings-set-models.ts`). They turn the flat Commander option bag into a
15
15
  * validated partial `ModelPolicy` (`ModelPolicySchema.parse`).
16
16
  */
17
- /** The three dispatch roles a policy can configure. */
17
+ /**
18
+ * The three dispatch roles a policy can configure. Derived from the
19
+ * single-source `PIPELINE_ROLES` (task #929); kept under the CLI-local names
20
+ * so existing call sites are undisturbed.
21
+ */
18
22
  export declare const MODEL_ROLES: readonly ["execution", "validation", "planning"];
19
- export type ModelRole = (typeof MODEL_ROLES)[number];
23
+ export type ModelRole = PipelineRole;
20
24
  /**
21
25
  * Register the per-role model flags on a Commander command:
22
26
  * --<role>-<category> <model|auto> (byCategory route)
@@ -41,4 +45,35 @@ export declare function buildModelPolicyFromOptions(options: Record<string, stri
41
45
  * `set-models` UX. Returns a schema-validated complete policy to persist.
42
46
  */
43
47
  export declare function mergeModelPolicies(existing: ModelPolicy | null | undefined, partial: ModelPolicy): ModelPolicy;
48
+ /**
49
+ * Read `--json` off the GLOBAL program options for a set-models subcommand.
50
+ * The flag is registered on the root program, so subcommands must reach up via
51
+ * `optsWithGlobals()`. Shared by `project-set-models` / `settings-set-models`.
52
+ */
53
+ export declare function resolveSetModelsJsonMode(command: Command): boolean;
54
+ /**
55
+ * Outcome of {@link parseSetModelsOptions}: either a validated partial policy
56
+ * ready to merge+persist, or a `stop` sentinel meaning the command already
57
+ * emitted its message and set `process.exitCode = 1` — the caller just returns.
58
+ */
59
+ export type ParseSetModelsResult = {
60
+ stop: true;
61
+ } | {
62
+ stop: false;
63
+ policy: ModelPolicy;
64
+ };
65
+ /**
66
+ * Shared set-models preamble for `project-set-models` / `settings-set-models`:
67
+ *
68
+ * 1. Assemble + validate a partial policy from the flat option bag
69
+ * (`buildModelPolicyFromOptions`). On a validation error, print it and
70
+ * set `process.exitCode = 1`, returning `{ stop: true }`.
71
+ * 2. When no model flags were supplied, warn and set `process.exitCode = 1`,
72
+ * returning `{ stop: true }`.
73
+ * 3. Otherwise return `{ stop: false, policy }`.
74
+ *
75
+ * Extracting this keeps the two commands' parse/validate/no-flags behavior and
76
+ * output byte-identical from one source.
77
+ */
78
+ export declare function parseSetModelsOptions(options: Record<string, string | undefined>): ParseSetModelsResult;
44
79
  export declare const modelsCommand: Command;
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { listModels } from '../api/client.js';
3
- import { colorWarn } from '../output/formatters.js';
3
+ import { colorError, colorWarn } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
- import { POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy.schema.js';
6
+ import { PIPELINE_ROLES, POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy.schema.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — CLI surface.
9
9
  *
@@ -18,8 +18,12 @@ import { POWER_CATEGORIES, ModelPolicySchema, } from '../../schemas/model-policy
18
18
  * `settings-set-models.ts`). They turn the flat Commander option bag into a
19
19
  * validated partial `ModelPolicy` (`ModelPolicySchema.parse`).
20
20
  */
21
- /** The three dispatch roles a policy can configure. */
22
- export const MODEL_ROLES = ['execution', 'validation', 'planning'];
21
+ /**
22
+ * The three dispatch roles a policy can configure. Derived from the
23
+ * single-source `PIPELINE_ROLES` (task #929); kept under the CLI-local names
24
+ * so existing call sites are undisturbed.
25
+ */
26
+ export const MODEL_ROLES = PIPELINE_ROLES;
23
27
  /**
24
28
  * Register the per-role model flags on a Commander command:
25
29
  * --<role>-<category> <model|auto> (byCategory route)
@@ -106,6 +110,46 @@ export function mergeModelPolicies(existing, partial) {
106
110
  }
107
111
  return ModelPolicySchema.parse(merged);
108
112
  }
113
+ /**
114
+ * Read `--json` off the GLOBAL program options for a set-models subcommand.
115
+ * The flag is registered on the root program, so subcommands must reach up via
116
+ * `optsWithGlobals()`. Shared by `project-set-models` / `settings-set-models`.
117
+ */
118
+ export function resolveSetModelsJsonMode(command) {
119
+ const globalOpts = command.parent?.optsWithGlobals() || {};
120
+ return Boolean(globalOpts['json']);
121
+ }
122
+ /**
123
+ * Shared set-models preamble for `project-set-models` / `settings-set-models`:
124
+ *
125
+ * 1. Assemble + validate a partial policy from the flat option bag
126
+ * (`buildModelPolicyFromOptions`). On a validation error, print it and
127
+ * set `process.exitCode = 1`, returning `{ stop: true }`.
128
+ * 2. When no model flags were supplied, warn and set `process.exitCode = 1`,
129
+ * returning `{ stop: true }`.
130
+ * 3. Otherwise return `{ stop: false, policy }`.
131
+ *
132
+ * Extracting this keeps the two commands' parse/validate/no-flags behavior and
133
+ * output byte-identical from one source.
134
+ */
135
+ export function parseSetModelsOptions(options) {
136
+ let policy;
137
+ try {
138
+ policy = buildModelPolicyFromOptions(options);
139
+ }
140
+ catch (error) {
141
+ const msg = error instanceof Error ? error.message : String(error);
142
+ console.error(colorError(`Invalid model policy: ${msg}`));
143
+ process.exitCode = 1;
144
+ return { stop: true };
145
+ }
146
+ if (policy === undefined) {
147
+ console.log(colorWarn('No model flags specified. Use --help to see available options.'));
148
+ process.exitCode = 1;
149
+ return { stop: true };
150
+ }
151
+ return { stop: false, policy };
152
+ }
109
153
  // ── `models list` ───────────────────────────────────────────
110
154
  const modelsListCommand = new Command('list')
111
155
  .description('List the available Claude model catalog (runtime-discovered)')
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { getProject, updateProject } from '../api/client.js';
3
- import { formatProjectDetail, colorError, colorWarn, colorSuccess } from '../output/formatters.js';
3
+ import { formatProjectDetail, 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
- import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies, } from './models.js';
6
+ import { addModelPolicyOptions, mergeModelPolicies, parseSetModelsOptions, resolveSetModelsJsonMode, } from './models.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — `tasks project-set-models <id> [flags]`.
9
9
  *
@@ -24,21 +24,10 @@ export const projectSetModelsCommand = addModelPolicyOptions(new Command('projec
24
24
  process.exitCode = 1;
25
25
  return;
26
26
  }
27
- let modelPolicy;
28
- try {
29
- modelPolicy = buildModelPolicyFromOptions(options);
30
- }
31
- catch (error) {
32
- const msg = error instanceof Error ? error.message : String(error);
33
- console.error(colorError(`Invalid model policy: ${msg}`));
34
- process.exitCode = 1;
35
- return;
36
- }
37
- if (modelPolicy === undefined) {
38
- console.log(colorWarn('No model flags specified. Use --help to see available options.'));
39
- process.exitCode = 1;
27
+ const parsed = parseSetModelsOptions(options);
28
+ if (parsed.stop)
40
29
  return;
41
- }
30
+ const modelPolicy = parsed.policy;
42
31
  // Fetch-merge-write: the server replaces the column wholesale, so merge
43
32
  // the partial flag policy over the stored one here to keep incremental
44
33
  // invocations non-destructive.
@@ -47,9 +36,7 @@ export const projectSetModelsCommand = addModelPolicyOptions(new Command('projec
47
36
  model_policy: mergeModelPolicies(current.model_policy ?? null, modelPolicy),
48
37
  };
49
38
  const project = await updateProject(id, updates);
50
- const program = projectSetModelsCommand.parent;
51
- const globalOpts = program?.optsWithGlobals() || {};
52
- const isJsonMode = globalOpts['json'] || false;
39
+ const isJsonMode = resolveSetModelsJsonMode(projectSetModelsCommand);
53
40
  if (isJsonMode) {
54
41
  jsonOutput({ project }, { id: project.id });
55
42
  }
@@ -1,12 +1,20 @@
1
1
  import { Command } from 'commander';
2
2
  import { type ChildProcess, type SpawnOptions } from 'child_process';
3
+ import { type CopySkillsResult, type CopyAgentsResult } from './setup.js';
3
4
  /**
4
5
  * `tasks self-update` — frictionless self-update for npm-global installs
5
6
  * (project #36, task #739).
6
7
  *
7
- * Spawns `npm i -g wood-fired-tasks@latest` and exits with that child's exit
8
- * code. The DB schema does NOT need touching here — migrate-on-next-serve
9
- * handles it the next time the service boots against the upgraded binary.
8
+ * Spawns `npm i -g wood-fired-tasks@latest`, then re-syncs the bundled
9
+ * skills/agents into ~/.claude (task #934): the npm install replaces the
10
+ * files under the global prefix in place, so the post-install copySkills /
11
+ * copyAgents read the NEW version's assets even though this process is still
12
+ * running the old binary. Without this step, any release that adds or
13
+ * changes a skill leaves self-updaters with stale ~/.claude/commands/tasks
14
+ * while self-update reports success — violating the README's "keep it up to
15
+ * date" contract. The DB schema does NOT need touching here —
16
+ * migrate-on-next-serve handles it the next time the service boots against
17
+ * the upgraded binary.
10
18
  *
11
19
  * EACCES policy: a global npm install under a root-owned prefix fails with
12
20
  * EACCES. We DO NOT escalate (no sudo / runas / elevation of ANY kind).
@@ -17,9 +25,14 @@ import { type ChildProcess, type SpawnOptions } from 'child_process';
17
25
  */
18
26
  export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
19
27
  export type NotifyFn = (currentVersion: string) => void | Promise<void>;
28
+ export type SyncAssetsFn = () => {
29
+ skills: CopySkillsResult;
30
+ agents: CopyAgentsResult;
31
+ };
20
32
  export interface SelfUpdateDeps {
21
33
  spawn?: SpawnFn;
22
34
  notify?: NotifyFn;
35
+ syncAssets?: SyncAssetsFn;
23
36
  }
24
37
  /**
25
38
  * True when a spawn error / non-zero exit looks like an EACCES-class
@@ -3,6 +3,7 @@ import { spawn as nodeSpawn } from 'child_process';
3
3
  import { colorError, colorInfo, colorSuccess, colorWarn } from '../output/formatters.js';
4
4
  import { VERSION } from '../../utils/version.js';
5
5
  import { buildNpmInvocation } from '../util/npm-spawn.js';
6
+ import { copySkills, copyAgents } from './setup.js';
6
7
  const PACKAGE_NAME = 'wood-fired-tasks';
7
8
  /**
8
9
  * True when a spawn error / non-zero exit looks like an EACCES-class
@@ -98,6 +99,7 @@ export const selfUpdateCommand = new Command('self-update')
98
99
  const deps = selfUpdateCommand._deps ?? {};
99
100
  const spawn = deps.spawn ?? nodeSpawn;
100
101
  const notify = deps.notify ?? defaultNotify;
102
+ const syncAssets = deps.syncAssets ?? (() => ({ skills: copySkills(), agents: copyAgents() }));
101
103
  // update-notifier nudge: surface a "newer version available" hint before
102
104
  // we attempt the upgrade. Best-effort and never blocks the update.
103
105
  await notify(VERSION);
@@ -119,6 +121,25 @@ export const selfUpdateCommand = new Command('self-update')
119
121
  process.exitCode = code ?? 1;
120
122
  return;
121
123
  }
124
+ // npm replaced the package files under the global prefix in place, so the
125
+ // sync below reads the NEW version's dist/skills even though this process
126
+ // still runs the old binary. A sync failure is loud and non-zero: "update
127
+ // succeeded but your skills are stale" is exactly the silent state this
128
+ // step exists to eliminate (task #934).
129
+ try {
130
+ const { skills, agents } = syncAssets();
131
+ const refreshed = skills.written.length + agents.written.length;
132
+ console.log(refreshed > 0
133
+ ? colorInfo(`Refreshed ${skills.written.length} skill(s) in ${skills.destDir} and ${agents.written.length} agent(s) in ${agents.destDir}`)
134
+ : colorInfo(`Skills and agents already up to date in ${skills.destDir}`));
135
+ }
136
+ catch (syncError) {
137
+ const message = syncError instanceof Error ? syncError.message : String(syncError);
138
+ console.error(colorError(`Update installed, but syncing bundled skills/agents into ~/.claude failed: ${message}`));
139
+ console.error(colorWarn(`Run \`${PACKAGE_NAME} setup\` to retry the skills sync.`));
140
+ process.exitCode = 1;
141
+ return;
142
+ }
122
143
  console.log(colorSuccess(`Updated ${PACKAGE_NAME}. The schema migrates automatically on next serve.`));
123
144
  process.exitCode = 0;
124
145
  });
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { getModelPolicyDefault, setModelPolicyDefault } from '../api/client.js';
3
- import { colorError, colorWarn, colorSuccess } from '../output/formatters.js';
3
+ import { colorSuccess } from '../output/formatters.js';
4
4
  import { handleError } from '../output/error-handler.js';
5
5
  import { jsonOutput } from '../output/json-output.js';
6
- import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies, } from './models.js';
6
+ import { addModelPolicyOptions, mergeModelPolicies, parseSetModelsOptions, resolveSetModelsJsonMode, } from './models.js';
7
7
  /**
8
8
  * Configurable Task Models (Task 12) — `tasks settings-set-models [flags]`.
9
9
  *
@@ -16,28 +16,15 @@ import { addModelPolicyOptions, buildModelPolicyFromOptions, mergeModelPolicies,
16
16
  */
17
17
  export const settingsSetModelsCommand = addModelPolicyOptions(new Command('settings-set-models').description('Set the database-wide default model policy (per-role / per-category routing)')).action(async (options) => {
18
18
  try {
19
- let modelPolicy;
20
- try {
21
- modelPolicy = buildModelPolicyFromOptions(options);
22
- }
23
- catch (error) {
24
- const msg = error instanceof Error ? error.message : String(error);
25
- console.error(colorError(`Invalid model policy: ${msg}`));
26
- process.exitCode = 1;
27
- return;
28
- }
29
- if (modelPolicy === undefined) {
30
- console.log(colorWarn('No model flags specified. Use --help to see available options.'));
31
- process.exitCode = 1;
19
+ const parsed = parseSetModelsOptions(options);
20
+ if (parsed.stop)
32
21
  return;
33
- }
22
+ const modelPolicy = parsed.policy;
34
23
  // Fetch-merge-write: keep incremental invocations non-destructive (the
35
24
  // server replaces the stored default wholesale).
36
25
  const current = await getModelPolicyDefault();
37
26
  const persisted = await setModelPolicyDefault(mergeModelPolicies(current, modelPolicy));
38
- const program = settingsSetModelsCommand.parent;
39
- const globalOpts = program?.optsWithGlobals() || {};
40
- const isJsonMode = globalOpts['json'] || false;
27
+ const isJsonMode = resolveSetModelsJsonMode(settingsSetModelsCommand);
41
28
  if (isJsonMode) {
42
29
  jsonOutput({ model_policy: persisted });
43
30
  }
@@ -333,6 +333,14 @@ export interface RunSetupInteractiveOptions extends RunSetupOptions {
333
333
  * here without standing up a full device-flow server.
334
334
  */
335
335
  deviceLogin?: typeof runDeviceLogin;
336
+ /**
337
+ * Injectable browser opener forwarded to the device-flow login (replaces the
338
+ * former no-browser env bandaid). Defaults to undefined, so production
339
+ * uses the real {@link openBrowser}. Tests that drive the REAL device flow
340
+ * (without stubbing {@link deviceLogin}) pass a stub here so the suite never
341
+ * spawns a real browser on a DISPLAY-set desktop.
342
+ */
343
+ opener?: (url: string) => boolean;
336
344
  /**
337
345
  * Injectable manual-PAT persistence seam (#809). Defaults to
338
346
  * {@link persistManualPat} (validate the PAT against `GET /api/v1/me`, then
@@ -720,6 +720,7 @@ export async function runRemoteOnboarding(options = {}) {
720
720
  clientId: process.env['OIDC_DEVICE_CLIENT_ID'] ?? 'wft-cli',
721
721
  hostname: os.hostname(),
722
722
  openBrowser: true,
723
+ ...(options.opener !== undefined && { opener: options.opener }),
723
724
  isJson: false,
724
725
  });
725
726
  deviceOk = result.ok;
@@ -15,6 +15,8 @@ export const updateCommand = new Command('update')
15
15
  .option('-a, --assignee <name>', 'New assignee')
16
16
  .option('--due <date>', 'New due date (ISO8601)')
17
17
  .option('--tags <tags>', 'New tags (comma-separated, replaces existing)')
18
+ .option('--blocked-by <ids>', 'Blocking task IDs (comma-separated). Only valid with --status blocked: ' +
19
+ 'adds the blocking dependency edge(s) and sets the status atomically')
18
20
  .action(async (idStr, options) => {
19
21
  try {
20
22
  // Parse and validate task ID
@@ -59,6 +61,24 @@ export const updateCommand = new Command('update')
59
61
  if (options.tags !== undefined) {
60
62
  updates.tags = options.tags.split(',').map((tag) => tag.trim());
61
63
  }
64
+ if (options.blockedBy !== undefined) {
65
+ // Task #1004: atomic block-with-dependency. Mirror the server's narrow
66
+ // semantics client-side so the misuse fails before the wire.
67
+ if (options.status !== 'blocked') {
68
+ console.error(colorError('--blocked-by is only valid together with --status blocked ' +
69
+ '(the edge-add and the status flip commit atomically). ' +
70
+ 'To add edges without blocking, use `tasks dep-add` instead.'));
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ const blockedBy = options.blockedBy.split(',').map((s) => parseInt(s.trim(), 10));
75
+ if (blockedBy.length === 0 || blockedBy.some((n) => isNaN(n) || n <= 0)) {
76
+ console.error(colorError('Invalid --blocked-by: must be comma-separated positive task IDs'));
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ updates.blocked_by = blockedBy;
81
+ }
62
82
  // Check if any updates were specified
63
83
  if (Object.keys(updates).length === 0) {
64
84
  console.log(colorWarn('No updates specified. Use --help to see available options.'));
@@ -1,4 +1,4 @@
1
- import { TaskEvent, ProjectEvent } from './types.js';
1
+ import { TaskEvent, ProjectEvent, ClaimReleasedEvent } from './types.js';
2
2
  /**
3
3
  * Options for {@link EventBus.subscribe}.
4
4
  *
@@ -90,6 +90,7 @@ type AppEvents = {
90
90
  'task.deleted': TaskEvent;
91
91
  'task.status_changed': TaskEvent;
92
92
  'task.claimed': TaskEvent;
93
+ 'task.claim_released': ClaimReleasedEvent;
93
94
  'project.created': ProjectEvent;
94
95
  'project.updated': ProjectEvent;
95
96
  'project.deleted': ProjectEvent;
@@ -178,6 +178,7 @@ export class EventBus {
178
178
  this.emitter.listenerCount('task.deleted') +
179
179
  this.emitter.listenerCount('task.status_changed') +
180
180
  this.emitter.listenerCount('task.claimed') +
181
+ this.emitter.listenerCount('task.claim_released') +
181
182
  this.emitter.listenerCount('project.created') +
182
183
  this.emitter.listenerCount('project.updated') +
183
184
  this.emitter.listenerCount('project.deleted'),
@@ -34,7 +34,7 @@ export declare class SSEManager {
34
34
  private heartbeatInterval?;
35
35
  private createdAt;
36
36
  private totalEventsSent;
37
- constructor(maxBufferSize?: number, bufferTtlMs?: number, // 5 minutes
37
+ constructor(maxBufferSize?: number, bufferTtlMs?: number, // 10 minutes — keep >= maxConnectionAgeMs
38
38
  heartbeatIntervalMs?: number, // 30 seconds
39
39
  maxConnectionAgeMs?: number, // 10 minutes
40
40
  maxConnectionsPerKey?: number, maxConnectionsPerIp?: number, maxConnections?: number);
@@ -56,13 +56,42 @@ export declare class SSEManager {
56
56
  apiKeyFingerprint: string;
57
57
  ip: string;
58
58
  }): void;
59
+ /**
60
+ * Remove a connection from the broadcast map WITHOUT touching the socket.
61
+ *
62
+ * task #1001: this is only safe when the peer socket is already gone — it
63
+ * is the cleanup callback for the raw `close`/`error` events registered in
64
+ * `addConnection`. Every server-initiated eviction (age-out, send failure,
65
+ * heartbeat failure, shutdown) MUST go through `closeConnection` instead,
66
+ * which ends the stream so the client observes a FIN and reconnects.
67
+ */
59
68
  removeConnection(connectionId: string): void;
69
+ /**
70
+ * task #1001: server-initiated eviction. Removes the connection from the
71
+ * broadcast map AND closes the underlying stream so the client sees the
72
+ * connection end and its reconnect logic fires (EventSource-style clients
73
+ * reconnect with `Last-Event-Id` and resume via `replayEvents`).
74
+ *
75
+ * Before this fix, the age-out path in the heartbeat sweep called
76
+ * `removeConnection` only — the map entry vanished but the TCP socket
77
+ * stayed open with no events, no pings and no FIN, so clients went
78
+ * silently deaf after maxConnectionAgeMs (reproduced 3x in the Tiny
79
+ * Worlds orchestration pilot, 2026-06-10).
80
+ */
81
+ private closeConnection;
60
82
  broadcast(event: EventPayload<unknown>): void;
61
83
  private matchesFilters;
62
84
  private sendEvent;
63
85
  private replayEvents;
64
86
  private pruneEventBuffer;
65
87
  private startHeartbeat;
88
+ /**
89
+ * One heartbeat tick: ping every live connection and evict connections
90
+ * past maxConnectionAgeMs. Extracted from the setInterval callback so
91
+ * integration tests can drive a sweep deterministically without faking
92
+ * 30s of wall-clock time against a real listening socket (task #1001).
93
+ */
94
+ private heartbeatSweep;
66
95
  shutdown(): void;
67
96
  /**
68
97
  * Check if SSE manager is healthy (has active heartbeat)