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 +21 -30
- package/package.json +1 -1
- package/src/create.mjs +3 -2
- package/src/input.mjs +2 -0
- package/src/stores.mjs +35 -5
- package/src/transition.mjs +6 -5
- package/src/validation.mjs +3 -2
package/README.md
CHANGED
|
@@ -1,48 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Outask
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
npm update -g outask
|
|
36
|
+
npm uninstall -g outask
|
|
41
37
|
```
|
|
42
38
|
|
|
43
|
-
|
|
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
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
|
|
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
|
|
296
|
+
for (const { value, index } of tasks) {
|
|
277
297
|
const label = `${path}[${index}]`;
|
|
278
|
-
|
|
279
|
-
|
|
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];
|
package/src/transition.mjs
CHANGED
|
@@ -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.
|
|
136
|
-
const removed = Object.keys(found.
|
|
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
|
package/src/validation.mjs
CHANGED
|
@@ -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:
|
|
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);
|