outask 1.6.0 → 1.7.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/README.md CHANGED
@@ -1,48 +1,39 @@
1
- # Install Outask with npm
1
+ # Outask
2
2
 
3
- Requires Node.js 22.23.3 or later and npm on PATH. Supports Linux x64,
4
- macOS Apple silicon and native Windows x64. npm creates both `outask` and
5
- `tasks` command entries in its global prefix. Ensure that prefix's command
6
- directory is on PATH; open a fresh terminal and restart your MCP client.
3
+ Outask is a lightweight task tracker that keeps tasks in readable JSON alongside your project code. Use its CLI from your terminal, or connect local AI agents through the optional MCP adapter.
4
+
5
+ ## Install
6
+
7
+ Requires Node.js 22.23.3 or later and npm on PATH.
7
8
 
8
9
  ```sh
9
10
  npm install -g outask
10
11
  outask --version
11
- outask create --title "Write release notes"
12
- outask list --format table
13
12
  ```
14
13
 
15
- Run create/list from your project directory. npm installation needs no
16
- application installer, shell-profile modification or administrator privileges
17
- when your Node provider supplies a writable global prefix. Select one active
18
- installation channel: remove archive command links with that archive's installer
19
- before switching to npm; check `command -v outask` (POSIX) or `where.exe outask`
20
- (Windows) to confirm which command is selected.
14
+ ## CLI quick start
15
+
16
+ Run from your project directory. Replace `1` with the ID returned by `create`.
21
17
 
22
18
  ```sh
23
- npm update -g outask
24
- npm uninstall -g outask
19
+ outask create --title "Write release notes"
20
+ outask list
21
+ outask get 1
22
+ outask update 1 --title "Review release notes"
23
+ outask transition 1 --status done
25
24
  ```
26
25
 
27
- npm owns application files and command shims. Its uninstall removes those files;
28
- project records, installed skills, application configuration and cache remain.
29
- Remove a Git hook with `outask hook --remove` before uninstalling if installed;
30
- remove the MCP entry in your client when no longer needed. Do not delete live
31
- coordination files. Let active writes finish; existing MCP sessions may retain
32
- loaded code until the client restarts them normally. Archive installer update
33
- commands do not update npm installations. `outask update` edits a task.
26
+ See the [operator reference](https://github.com/henryh/outask/blob/main/app/README.md) for project selection, task fields and configured statuses.
34
27
 
35
28
  ## Connect MCP
36
29
 
37
- Use `outask` with argument `mcp` on Linux/macOS:
30
+ Configure your MCP client to run `outask mcp`. Tool calls select your repository with an absolute `project_path`. See [MCP setup](https://github.com/henryh/outask/blob/main/app/mcp/README.md) for client configuration.
31
+
32
+ ## Update or remove
38
33
 
39
34
  ```sh
40
- codex mcp add outask -- outask mcp
35
+ npm update -g outask
36
+ npm uninstall -g outask
41
37
  ```
42
38
 
43
- On Windows, use your client's documented npm command-shim support, or configure
44
- `node` with the absolute global package `bin/outask` path and argument `mcp`.
45
- Find the package directory with `npm root -g`. Tool calls still select the
46
- repository using an absolute `project_path`.
47
-
48
- See [MCP setup](https://github.com/henryh/outask/blob/main/app/mcp/README.md) and [operator commands](https://github.com/henryh/outask/blob/main/app/README.md).
39
+ Uninstall removes the npm package and its commands; project tasks, skills, configuration and cache remain. See [installation and updates](https://github.com/henryh/outask/blob/main/app/README.md#installation-and-updates) for lifecycle details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outask",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Outask CLI for repository-local JSON task tracking",
5
5
  "author": "Henry <me@henrydev.me>",
6
6
  "license": "AGPL-3.0-only",
package/src/create.mjs CHANGED
@@ -9,7 +9,7 @@ import { inputBytes, projectBytes, responseBytes } from './limits.mjs';
9
9
  import { jsonBytes } from './response.mjs';
10
10
  import { knownCounter, readService, recoverMove, replaceFile, serviceState, withProjectLock } from './service.mjs';
11
11
  import { finishStore, formatValue } from './storage-format.mjs';
12
- import { openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
12
+ import { effectiveRecord, openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
13
13
  import { scanProject, validateCycles, validateRecord } from './validation.mjs';
14
14
 
15
15
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -59,9 +59,10 @@ function readActive(project, target, fingerprints) {
59
59
  source = text;
60
60
  count = 0;
61
61
  }
62
- for (const { value: record } of records) {
62
+ for (const { value } of records) {
63
63
  if (file === target) count++;
64
64
  if (!activeStore) continue;
65
+ const record = rules.validate === false && rules.missing_status === undefined ? value : effectiveRecord(value, project, file, `tasks/${file}`);
65
66
  if (project.groups.get(record.status) !== 'Active') continue;
66
67
  activeIds.add(record.id);
67
68
  if (rules.validate !== false) active.set(record.id, { blockers: record.blockers ?? [] });
package/src/input.mjs CHANGED
@@ -123,6 +123,8 @@ export function* arrayRecords(source, label) {
123
123
  index++;
124
124
  if (source[offset] === 0x2c) {
125
125
  offset++;
126
+ while (whitespace(source[offset])) offset++;
127
+ if (source[offset] === 0x5d) fail('INVALID_JSON', `${label}: invalid JSON; correct the input and retry`);
126
128
  continue;
127
129
  }
128
130
  if (source[offset] !== 0x5d) fail('INVALID_JSON', `${label}: invalid JSON; correct the input and retry`);
package/src/stores.mjs CHANGED
@@ -13,8 +13,8 @@ export const serviceFile = '.task-tracker.json';
13
13
  export const pendingFile = '.task-tracker.pending';
14
14
 
15
15
  export const standardStores = {
16
- 'active.json': { statuses: ['pending', 'in_progress', 'review'] },
17
- 'done.json': { statuses: ['done'] },
16
+ 'active.json': { statuses: ['pending', 'in_progress', 'review'], missing_status: 'pending' },
17
+ 'done.json': { statuses: ['done'], missing_status: 'done' },
18
18
  'archive.json': { statuses: ['cancelled'] },
19
19
  };
20
20
  export const defaultSearch = { mode: 'scan' };
@@ -102,6 +102,13 @@ function readConfig(path, budget, explicit, directory) {
102
102
  invalid(`store ${name} validate must be a boolean`);
103
103
  }
104
104
  }
105
+ for (const [name, store] of Object.entries(config.stores)) {
106
+ if (Object.hasOwn(store, 'missing_status') && (!isText(store.missing_status)
107
+ || !store.statuses.includes(store.missing_status)
108
+ || ['in_progress', 'review', ...(Array.isArray(config.working_statuses) ? config.working_statuses : [])].includes(store.missing_status))) {
109
+ invalid(`store ${name} missing_status must name a status in stores["${name}"].statuses that is not a working status; set a compatible missing_status manually`);
110
+ }
111
+ }
105
112
  const statuses = new Set(Object.values(config.stores).flatMap((store) => store.statuses));
106
113
  validateFieldRules(config.fields, path, statuses);
107
114
  for (const [name, store] of Object.entries(config.stores)) {
@@ -266,6 +273,19 @@ export function selectStoreFiles(project, { stores = [], group } = {}) {
266
273
  return { files: uninitialised ? [] : files, selectedGroup };
267
274
  }
268
275
 
276
+ // Keep persisted records separate: inference is read-only until an actual mutation.
277
+ export function effectiveRecord(task, project, file, label) {
278
+ if (!isObject(task) || Object.hasOwn(task, 'status')) return task;
279
+ const status = project.stores[file]?.missing_status;
280
+ if (status === undefined) {
281
+ const correction = project.config === undefined
282
+ ? 'set an explicit status on this record or configure its store with a non-working missing_status'
283
+ : `set stores["${file}"].missing_status in ${project.schemaPath} to a declared non-working status allowed by this store`;
284
+ fail('INVALID_STORE', `${label}: missing status; ${correction}; no fallback is configured for ${file}`);
285
+ }
286
+ return { ...task, status };
287
+ }
288
+
269
289
  export function recordEntries(project, options = {}) {
270
290
  const { files, selectedGroup } = selectStoreFiles(project, options);
271
291
  const entries = (function* iterate() {
@@ -273,10 +293,20 @@ export function recordEntries(project, options = {}) {
273
293
  for (const file of files) {
274
294
  const path = join(project.directory, file);
275
295
  const { records: tasks } = readArray(path, project.budget);
276
- for (const { value: task, index } of tasks) {
296
+ for (const { value, index } of tasks) {
277
297
  const label = `${path}[${index}]`;
278
- if (!isObject(task) || !Number.isSafeInteger(task.id) || task.id <= 0 || !isText(task.title) || !isText(task.status)) {
279
- fail('INVALID_STORE', `${label}: task needs a positive safe integer id and non-empty title/status strings`);
298
+ let task = value;
299
+ if (!isObject(task)) {
300
+ fail('INVALID_STORE', `${label}: task must be an object; correct this record to include id, title and status`);
301
+ }
302
+ if (Number.isSafeInteger(task.id) && task.id > 0 && isText(task.title)) task = effectiveRecord(task, project, file, label);
303
+ const invalidField = !Number.isSafeInteger(task.id) || task.id <= 0 ? 'id'
304
+ : !isText(task.title) ? 'title' : !isText(task.status) ? 'status' : undefined;
305
+ if (invalidField) {
306
+ const expected = invalidField === 'id' ? 'a positive safe integer unique across retained tasks'
307
+ : invalidField === 'status' ? "a non-empty string matching the project's workflow" : 'a non-empty string';
308
+ const problem = Object.hasOwn(task, invalidField) ? 'incompatible' : 'missing';
309
+ fail('INVALID_STORE', `${label}: ${problem} ${invalidField}; set ${invalidField} to ${expected}. Schema rules cannot waive this read minimum`);
280
310
  }
281
311
  validateTimestamp(task, label);
282
312
  const rules = project.stores[file];
@@ -8,7 +8,7 @@ import { jsonBytes } from './response.mjs';
8
8
  import { fingerprint, knownCounter, readService, recoverMove, saveMove, serviceState, withProjectLock } from './service.mjs';
9
9
  import { changeFields } from './source.mjs';
10
10
  import { finishStore } from './storage-format.mjs';
11
- import { openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
11
+ import { effectiveRecord, openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
12
12
  import { nextTimestamp, timestampValue, validateTimestamp } from './timestamps.mjs';
13
13
  import { scanProject, validateCycles, validateRecord } from './validation.mjs';
14
14
 
@@ -87,10 +87,11 @@ export function moveTask(projectPath, id, status, options, deleting, patch) {
87
87
  sources.set(file, selected);
88
88
  let previousEnd;
89
89
  for (const entry of records) {
90
- const task = entry.value;
90
+ const task = rules.validate === false && rules.missing_status === undefined && entry.value.id !== id
91
+ ? entry.value : effectiveRecord(entry.value, project, file, `tasks/${file}`);
91
92
  selected.count++;
92
93
  if (found?.file === file && found.nextStart === undefined) found.nextStart = entry.start;
93
- if (task.id === id) found = { file, task, start: entry.start, end: entry.end, previousEnd };
94
+ if (task.id === id) found = { file, task, persisted: entry.value, start: entry.start, end: entry.end, previousEnd };
94
95
  if (project.groups.get(task.status) === 'Active') {
95
96
  activeIds.add(task.id);
96
97
  if (rules.validate !== false) active.set(task.id, { blockers: task.blockers ?? [] });
@@ -132,8 +133,8 @@ export function moveTask(projectPath, id, status, options, deleting, patch) {
132
133
  if (jsonBytes(task) + 1 > responseBytes) fail('OUTPUT_TOO_LARGE', 'the complete transitioned task exceeds 32 KiB; shorten the record before moving it');
133
134
  if (!changed) return task;
134
135
  const selected = sources.get(found.file);
135
- const fields = Object.fromEntries(Object.entries(task).filter(([key, value]) => !Object.hasOwn(found.task, key) || !same(value, found.task[key])));
136
- const removed = Object.keys(found.task).filter((key) => !Object.hasOwn(task, key));
136
+ const fields = Object.fromEntries(Object.entries(task).filter(([key, value]) => !Object.hasOwn(found.persisted, key) || !same(value, found.persisted[key])));
137
+ const removed = Object.keys(found.persisted).filter((key) => !Object.hasOwn(task, key));
137
138
  const lineStart = selected.source.lastIndexOf(0x0a, found.start - 1) + 1;
138
139
  const prefix = selected.source.toString('utf8', lineStart, found.start);
139
140
  const indent = target === found.file
@@ -4,7 +4,7 @@ import { applyFieldRules } from './field-rules.mjs';
4
4
  import { validateTimestamp } from './timestamps.mjs';
5
5
  import { projectBudget, readArray, readBuffer } from './input.mjs';
6
6
  import { knownCounter, readService, recoverMove, replaceFile, serviceState, withProjectLock } from './service.mjs';
7
- import { openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
7
+ import { effectiveRecord, openProject, projectRelative, retainedFiles, serviceFile } from './stores.mjs';
8
8
 
9
9
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
10
10
  const text = (value) => typeof value === 'string' && value.trim() !== '';
@@ -93,8 +93,9 @@ export function scanProject(projectPath, validate = true, schemaPath) {
93
93
  continue;
94
94
  }
95
95
  const { records } = readArray(join(project.directory, file), budget);
96
- for (const { value: task, index } of records) {
96
+ for (const { value: persisted, index } of records) {
97
97
  const label = `tasks/${file}[${index}]`;
98
+ const task = project.stores[file]?.validate === false ? persisted : effectiveRecord(persisted, project, file, label);
98
99
  if (!object(task) || !id(task.id)) fail('INVALID_STORE', `${label}: id must be a positive safe integer on a task object`);
99
100
  if (seen.has(task.id)) fail('INVALID_STORE', `${label}: duplicate id ${task.id}; first seen at ${seen.get(task.id)}`);
100
101
  seen.set(task.id, label);