create-issflow 1.11.1 → 1.12.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/bin/cli.js +23 -21
- package/package.json +1 -1
- package/template/.issflow/README.md +4 -1
- package/template/.issflow/agents/planner.md +7 -2
- package/template/.issflow/agents/synthesizer.md +12 -5
- package/template/.issflow/commands/feature.md +30 -15
- package/template/.issflow/commands/goal.md +17 -8
- package/template/.issflow/commands/phase.md +62 -32
- package/template/.issflow/commands/quick.md +14 -4
- package/template/.issflow/commands/sprint.md +21 -12
- package/template/.issflow/commands/synthesize.md +10 -6
- package/template/.issflow/hooks/pre-compact.js +4 -0
- package/template/.issflow/hooks/session-start.js +29 -4
- package/template/.issflow/istartsoft-flow/METHODOLOGY.md +135 -35
- package/template/.issflow/models/MODEL-MATRIX.md +6 -6
- package/template/.issflow/routing/package.json +1 -0
- package/template/.issflow/runtime/HOSTS.md +41 -0
- package/template/.issflow/runtime/README.md +150 -0
- package/template/.issflow/runtime/package.json +1 -0
- package/template/.issflow/runtime/workflow.js +187 -0
- package/template/.issflow/scripts/package.json +1 -0
- package/template/.issflow/skills/ux-design/SKILL.md +3 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Local workflow checkpoint helper
|
|
2
|
+
|
|
3
|
+
`workflow.js` is a dependency-free Node.js CLI and CommonJS API. It persists a
|
|
4
|
+
small goal/task DAG and selects the next eligible task. It does **not** run
|
|
5
|
+
commands, start workers, verify tests, grant approval, or run as a daemon. An
|
|
6
|
+
agent or person still performs work under the project's existing approval rules.
|
|
7
|
+
Use Node.js 18 or later. Run tests from the repository root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
node --test tests/workflow-runtime.test.js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Create `goal.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"goal": "Implement and verify the approved change",
|
|
20
|
+
"budget": 8,
|
|
21
|
+
"tasks": [
|
|
22
|
+
{ "id": "implement", "title": "Implement approved scope", "maxAttempts": 2, "units": 2 },
|
|
23
|
+
{ "id": "test", "title": "Verify the final change", "dependsOn": ["implement"] },
|
|
24
|
+
{ "id": "docs", "title": "Update usage examples" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Choose a local checkpoint path with an existing parent directory. Do not store
|
|
30
|
+
secrets in state, reasons, or evidence. Keep personal run state out of commits.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
node .issflow/runtime/workflow.js init workflow-state.json goal.json
|
|
34
|
+
node .issflow/runtime/workflow.js status workflow-state.json
|
|
35
|
+
node .issflow/runtime/workflow.js next workflow-state.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`next` atomically claims a single runnable task, records `running`, increments its
|
|
39
|
+
attempt count, and charges `units` to the global budget **before** external work
|
|
40
|
+
starts. It returns `{ "task": ..., "status": ... }`. It does not do the task.
|
|
41
|
+
Independent tasks remain runnable when another task is blocked, waiting, or
|
|
42
|
+
running. A task with unfinished dependencies cannot be claimed.
|
|
43
|
+
|
|
44
|
+
After actually doing and verifying the selected work, create `result.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"state": "done",
|
|
49
|
+
"expectedAttempt": 1,
|
|
50
|
+
"codeRevision": "git-tree:<exact-tested-tree-or-head-plus-diff-identity>",
|
|
51
|
+
"evidence": ["test: node --test tests/example.test.js; passed; log: <path>"],
|
|
52
|
+
"expectedRevision": 1
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
node .issflow/runtime/workflow.js update workflow-state.json implement result.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Replace the placeholders with real evidence and the actual tested code identity.
|
|
61
|
+
`expectedAttempt` must match the claimed task's attempt number. `expectedRevision`
|
|
62
|
+
is mandatory optimistic concurrency protection for every update and must match
|
|
63
|
+
the **workflow** revision from the latest response, not a Git revision. If another
|
|
64
|
+
task changed the workflow, reload it, reconcile the result, and then submit a new
|
|
65
|
+
update; never blindly retry a stale update.
|
|
66
|
+
|
|
67
|
+
## States, retries, and stopping
|
|
68
|
+
|
|
69
|
+
Task states are `pending`, `running`, `blocked`, `waiting`, and `done`.
|
|
70
|
+
|
|
71
|
+
- `next` is the only way to start a task. No shell command is inferred from titles.
|
|
72
|
+
- A `done` update requires a running task, the current `expectedAttempt`, a
|
|
73
|
+
nonempty `codeRevision`, and at least one nonempty evidence string.
|
|
74
|
+
- `blocked` and `waiting` updates require a nonempty `reason`. For example,
|
|
75
|
+
`{"state":"blocked","reason":"Needs approval for expanded file scope","expectedRevision":1}`.
|
|
76
|
+
- Mark an unfinished task `pending` to explicitly release it for another attempt.
|
|
77
|
+
`{"state":"pending","expectedRevision":1}` also clears prior evidence and code identity. The next
|
|
78
|
+
claim charges another attempt and units. No refund occurs after failed work.
|
|
79
|
+
- `maxAttempts` and the total budget are immutable. Exhausted tasks are not
|
|
80
|
+
claimable even if marked pending. A lower-cost independent task can still run.
|
|
81
|
+
- Completed tasks and task definitions are immutable. Changed scope or invalidated
|
|
82
|
+
completed work requires a new reviewed workflow rather than silently resetting
|
|
83
|
+
limits or reusing a stale completion record.
|
|
84
|
+
|
|
85
|
+
Aggregate status is `ready` if work is claimable, `done` if all tasks completed,
|
|
86
|
+
`waiting` if no work is claimable but a task is running or explicitly waiting,
|
|
87
|
+
and otherwise `blocked`. The `tasks[].blockers` list explains pending dependency,
|
|
88
|
+
attempt-limit, and budget constraints. `next` returns `task: null` without changing
|
|
89
|
+
the checkpoint if there is no runnable task. It never loops, waits, or retries.
|
|
90
|
+
|
|
91
|
+
## Resume and evidence boundary
|
|
92
|
+
|
|
93
|
+
Every mutation is a checkpoint. After restart, run `status` and inspect unfinished
|
|
94
|
+
work. Running tasks stay running: they are never silently reclaimed or repeated.
|
|
95
|
+
If the original worker is gone, inspect its effects before explicitly returning
|
|
96
|
+
its task to pending. Waiting and blocked tasks also need an explicit update.
|
|
97
|
+
|
|
98
|
+
Evidence is **caller-attested**. The helper binds completion to the latest attempt
|
|
99
|
+
and records the supplied code identity; it does not inspect Git, compare that
|
|
100
|
+
identity to the working tree, execute tests, or prove a log is truthful. The caller
|
|
101
|
+
must rerun relevant checks after edits and provide the identity of the exact code
|
|
102
|
+
checked, including uncommitted changes. A later edit can invalidate a prior done
|
|
103
|
+
record; `status` cannot detect that. Do not present persisted `done` as proof that
|
|
104
|
+
the current checkout passes. This helper is not a replacement for CI, approval
|
|
105
|
+
validation, or review.
|
|
106
|
+
|
|
107
|
+
## Schemas and API
|
|
108
|
+
|
|
109
|
+
Plan: `{goal, budget, tasks}`. `goal` and task `title` must be nonempty strings;
|
|
110
|
+
`budget`, `units`, and `maxAttempts` must be positive safe integers. Each task is
|
|
111
|
+
`{id,title,dependsOn?,maxAttempts?,units?}`. Defaults are `[]`, `3`, and `1`.
|
|
112
|
+
IDs match `[A-Za-z0-9][A-Za-z0-9_-]{0,99}`. There must be 1–1000 tasks. Unknown,
|
|
113
|
+
duplicate, self, and cyclic dependencies are rejected. Unknown fields fail closed.
|
|
114
|
+
|
|
115
|
+
Update: `{state,reason?,evidence?,codeRevision?,expectedAttempt?,expectedRevision}`.
|
|
116
|
+
`state` may be pending/blocked/waiting/done, never running. `evidence` is an array
|
|
117
|
+
of nonempty strings. A done update requires evidence, codeRevision, and
|
|
118
|
+
expectedAttempt. Every update requires the current expectedRevision. Updates replace reason/evidence/codeRevision; omitted values
|
|
119
|
+
clear them. Callers cannot mutate attempts, budgets, or definitions via updates.
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const workflow = require('./.issflow/runtime/workflow.js');
|
|
123
|
+
workflow.init('/existing/directory/state.json', plan); // returns persisted state
|
|
124
|
+
workflow.status(file); // derived summary
|
|
125
|
+
workflow.next(file); // {task, status}
|
|
126
|
+
workflow.update(file, taskId, patch); // derived summary
|
|
127
|
+
workflow.load(file); // validated raw state
|
|
128
|
+
workflow.validate(state); // validates, returns state
|
|
129
|
+
workflow.summarize(state); // summary without file I/O
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
API calls are synchronous and throw on invalid input. CLI success emits JSON on
|
|
133
|
+
stdout; failures emit an error on stderr and exit nonzero. There is no implicit
|
|
134
|
+
default checkpoint path. `init` refuses to overwrite an existing file.
|
|
135
|
+
|
|
136
|
+
## Persistence safeguards and limits
|
|
137
|
+
|
|
138
|
+
Writes use a same-directory private temporary file, file fsync, and atomic rename.
|
|
139
|
+
Exclusive `.lock` files serialize cooperating writers. A crash can leave a lock:
|
|
140
|
+
verify that no writer is active before manually removing the stale lock. Locks
|
|
141
|
+
are not automatically expired. Readers see the previous or next complete snapshot.
|
|
142
|
+
Directory entries are not fsynced, so sudden power-loss durability is not promised.
|
|
143
|
+
|
|
144
|
+
State and JSON input files are limited to 4 MiB and must be ordinary single-link
|
|
145
|
+
files. Symbolic links in the file or its parent path are rejected. Parent directories
|
|
146
|
+
must exist. On systems where `/tmp` is a symlink (such as macOS), use its canonical
|
|
147
|
+
physical path instead. Malformed/corrupt state is rejected without replacing it. These checks
|
|
148
|
+
protect against accidental redirection, not hostile users racing filesystem
|
|
149
|
+
changes. Local files remain editable by their owner; validation is not cryptographic
|
|
150
|
+
tamper detection. Never place the checkpoint in an untrusted shared directory.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"private":true,"type":"commonjs"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// A checkpoint and scheduling helper, not an executor or approval authority.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
const STATES = ['pending', 'running', 'blocked', 'waiting', 'done'];
|
|
10
|
+
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
11
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
const nonempty = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
13
|
+
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min;
|
|
14
|
+
function keys(value, allowed) {
|
|
15
|
+
assert(object(value), 'Expected an object');
|
|
16
|
+
assert(Object.keys(value).every((key) => allowed.includes(key)), 'Unknown field');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validate(state) {
|
|
20
|
+
keys(state, ['version', 'goal', 'budget', 'spent', 'revision', 'tasks']);
|
|
21
|
+
assert(state.version === 1 && nonempty(state.goal), 'Invalid version or goal');
|
|
22
|
+
assert(integer(state.budget, 1) && integer(state.spent) && state.spent <= state.budget && integer(state.revision), 'Invalid budget or revision');
|
|
23
|
+
assert(Array.isArray(state.tasks) && state.tasks.length > 0 && state.tasks.length <= 1000, 'Expected 1–1000 tasks');
|
|
24
|
+
const byId = new Map();
|
|
25
|
+
for (const task of state.tasks) {
|
|
26
|
+
keys(task, ['id', 'title', 'dependsOn', 'maxAttempts', 'units', 'attempts', 'state', 'reason', 'evidence', 'codeRevision']);
|
|
27
|
+
assert(typeof task.id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(task.id) && !byId.has(task.id), 'Invalid or duplicate task id');
|
|
28
|
+
assert(nonempty(task.title) && STATES.includes(task.state), 'Invalid title or task state');
|
|
29
|
+
assert(integer(task.maxAttempts, 1) && integer(task.units, 1) && integer(task.attempts) && task.attempts <= task.maxAttempts, 'Invalid task limits');
|
|
30
|
+
assert(Array.isArray(task.dependsOn) && task.dependsOn.every((id) => typeof id === 'string') && new Set(task.dependsOn).size === task.dependsOn.length, 'Invalid dependencies');
|
|
31
|
+
assert(typeof task.codeRevision === 'string', 'Invalid code revision');
|
|
32
|
+
assert(typeof task.reason === 'string' && Array.isArray(task.evidence) && task.evidence.every(nonempty), 'Invalid reason or evidence');
|
|
33
|
+
if (['running', 'done'].includes(task.state)) assert(task.attempts > 0, 'Task has not been attempted');
|
|
34
|
+
if (['blocked', 'waiting'].includes(task.state)) assert(nonempty(task.reason), 'Blocked/waiting requires a reason');
|
|
35
|
+
if (task.state === 'done') {
|
|
36
|
+
assert(task.evidence.length > 0, 'Done requires evidence');
|
|
37
|
+
assert(nonempty(task.codeRevision), 'Done requires codeRevision');
|
|
38
|
+
}
|
|
39
|
+
byId.set(task.id, task);
|
|
40
|
+
}
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const task of state.tasks) {
|
|
43
|
+
total += task.units * task.attempts;
|
|
44
|
+
assert(Number.isSafeInteger(total), 'Budget overflow');
|
|
45
|
+
for (const id of task.dependsOn) {
|
|
46
|
+
assert(byId.has(id) && id !== task.id, 'Unknown or self dependency');
|
|
47
|
+
if (['running', 'done'].includes(task.state)) assert(byId.get(id).state === 'done', 'Dependencies are not done');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
assert(total === state.spent, 'Spent budget does not match attempts');
|
|
51
|
+
const visiting = new Set(); const visited = new Set();
|
|
52
|
+
function visit(id) {
|
|
53
|
+
assert(!visiting.has(id), 'Dependency cycle');
|
|
54
|
+
if (visited.has(id)) return;
|
|
55
|
+
visiting.add(id);
|
|
56
|
+
for (const dep of byId.get(id).dependsOn) visit(dep);
|
|
57
|
+
visiting.delete(id); visited.add(id);
|
|
58
|
+
}
|
|
59
|
+
for (const id of byId.keys()) visit(id);
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Existing parent directory required. Refuse symlinks in every path component.
|
|
64
|
+
// This protects accidental redirection, not hostile concurrent filesystem changes.
|
|
65
|
+
function safePath(file) {
|
|
66
|
+
assert(nonempty(file), 'A file path is required');
|
|
67
|
+
const full = path.resolve(file);
|
|
68
|
+
let part = path.parse(full).root;
|
|
69
|
+
const pieces = full.slice(part.length).split(path.sep);
|
|
70
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
71
|
+
part = path.join(part, pieces[i]);
|
|
72
|
+
let stat;
|
|
73
|
+
try { stat = fs.lstatSync(part); } catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT' && i === pieces.length - 1) return full;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
assert(!stat.isSymbolicLink(), 'Symbolic links are not allowed');
|
|
78
|
+
assert(i === pieces.length - 1 ? stat.isFile() && stat.nlink === 1 : stat.isDirectory(), 'Expected an ordinary file in a directory');
|
|
79
|
+
}
|
|
80
|
+
return full;
|
|
81
|
+
}
|
|
82
|
+
function readJSON(file) {
|
|
83
|
+
const full = safePath(file);
|
|
84
|
+
const fd = fs.openSync(full, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
85
|
+
try {
|
|
86
|
+
const stat = fs.fstatSync(fd);
|
|
87
|
+
assert(stat.isFile() && stat.nlink === 1 && stat.size <= MAX_BYTES, 'Invalid or oversized JSON file');
|
|
88
|
+
return JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
89
|
+
} finally { fs.closeSync(fd); }
|
|
90
|
+
}
|
|
91
|
+
function load(file) { return validate(readJSON(file)); }
|
|
92
|
+
function write(file, state) {
|
|
93
|
+
validate(state);
|
|
94
|
+
const text = JSON.stringify(state, null, 2) + '\n';
|
|
95
|
+
assert(Buffer.byteLength(text) <= MAX_BYTES, 'State exceeds size limit');
|
|
96
|
+
safePath(file);
|
|
97
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = fs.openSync(temp, 'wx', 0o600);
|
|
101
|
+
fs.writeFileSync(fd, text); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
102
|
+
fs.renameSync(temp, file);
|
|
103
|
+
} finally {
|
|
104
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
105
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function locked(file, operation) {
|
|
109
|
+
file = safePath(file);
|
|
110
|
+
const lock = `${file}.lock`;
|
|
111
|
+
let fd;
|
|
112
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); }
|
|
113
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('State is locked; verify no writer is active before removing a stale .lock file'); throw error; }
|
|
114
|
+
try { return operation(file); }
|
|
115
|
+
finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
|
116
|
+
}
|
|
117
|
+
function init(file, spec) {
|
|
118
|
+
keys(spec, ['goal', 'budget', 'tasks']);
|
|
119
|
+
assert(Array.isArray(spec.tasks), 'Expected tasks');
|
|
120
|
+
const state = { version: 1, goal: spec.goal, budget: spec.budget, spent: 0, revision: 0,
|
|
121
|
+
tasks: spec.tasks.map((task) => {
|
|
122
|
+
keys(task, ['id', 'title', 'dependsOn', 'maxAttempts', 'units']);
|
|
123
|
+
return { id: task.id, title: task.title, dependsOn: task.dependsOn ?? [], maxAttempts: task.maxAttempts ?? 3,
|
|
124
|
+
units: task.units ?? 1, attempts: 0, state: 'pending', reason: '', evidence: [], codeRevision: '' };
|
|
125
|
+
}) };
|
|
126
|
+
validate(state);
|
|
127
|
+
return locked(file, (full) => { assert(!fs.existsSync(full), 'State already exists'); write(full, state); return state; });
|
|
128
|
+
}
|
|
129
|
+
function summarize(state) {
|
|
130
|
+
validate(state);
|
|
131
|
+
const done = new Set(state.tasks.filter((t) => t.state === 'done').map((t) => t.id));
|
|
132
|
+
const runnable = state.tasks.filter((t) => t.state === 'pending' && t.attempts < t.maxAttempts &&
|
|
133
|
+
t.units <= state.budget - state.spent && t.dependsOn.every((id) => done.has(id))).map((t) => t.id);
|
|
134
|
+
const stateName = state.tasks.every((t) => t.state === 'done') ? 'done' : runnable.length ? 'ready' :
|
|
135
|
+
state.tasks.some((t) => ['running', 'waiting'].includes(t.state)) ? 'waiting' : 'blocked';
|
|
136
|
+
return { goal: state.goal, state: stateName, revision: state.revision, budget: state.budget, spent: state.spent,
|
|
137
|
+
remaining: state.budget - state.spent, runnable, tasks: state.tasks.map((t) => ({ ...t,
|
|
138
|
+
blockers: t.state === 'pending' ? [
|
|
139
|
+
...(t.attempts >= t.maxAttempts ? ['attempt limit reached'] : []),
|
|
140
|
+
...(t.units > state.budget - state.spent ? ['unit budget exhausted'] : []),
|
|
141
|
+
...t.dependsOn.filter((id) => !done.has(id)).map((id) => `dependency: ${id}`)
|
|
142
|
+
] : [] })) };
|
|
143
|
+
}
|
|
144
|
+
function status(file) { return summarize(load(file)); }
|
|
145
|
+
function next(file) {
|
|
146
|
+
return locked(file, (full) => {
|
|
147
|
+
const state = load(full); const summary = summarize(state);
|
|
148
|
+
if (!summary.runnable.length) return { task: null, status: summary };
|
|
149
|
+
const task = state.tasks.find((t) => t.id === summary.runnable[0]);
|
|
150
|
+
task.state = 'running'; task.reason = ''; task.evidence = []; task.codeRevision = ''; task.attempts++;
|
|
151
|
+
state.spent += task.units; state.revision++;
|
|
152
|
+
write(full, state);
|
|
153
|
+
return { task, status: summarize(state) };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function update(file, id, patch) {
|
|
157
|
+
keys(patch, ['state', 'reason', 'evidence', 'expectedRevision', 'expectedAttempt', 'codeRevision']);
|
|
158
|
+
assert(['pending', 'blocked', 'waiting', 'done'].includes(patch.state), 'Invalid update state; use next to start work');
|
|
159
|
+
if (patch.codeRevision !== undefined) assert(nonempty(patch.codeRevision), 'Invalid codeRevision');
|
|
160
|
+
if (patch.reason !== undefined) assert(typeof patch.reason === 'string', 'Invalid reason');
|
|
161
|
+
if (patch.evidence !== undefined) assert(Array.isArray(patch.evidence) && patch.evidence.every(nonempty), 'Invalid evidence');
|
|
162
|
+
return locked(file, (full) => {
|
|
163
|
+
const state = load(full);
|
|
164
|
+
assert(integer(patch.expectedRevision) && patch.expectedRevision === state.revision, 'Stale or missing expectedRevision');
|
|
165
|
+
const task = state.tasks.find((t) => t.id === id);
|
|
166
|
+
assert(task, 'Unknown task'); assert(task.state !== 'done', 'Completed tasks are immutable');
|
|
167
|
+
if (patch.state === 'done') {
|
|
168
|
+
assert(task.state === 'running', 'Only running tasks can complete');
|
|
169
|
+
assert(integer(patch.expectedAttempt, 1) && patch.expectedAttempt === task.attempts, 'Done requires the current expectedAttempt; stale evidence rejected');
|
|
170
|
+
}
|
|
171
|
+
task.state = patch.state; task.reason = patch.reason ?? ''; task.evidence = patch.evidence ?? []; task.codeRevision = patch.codeRevision ?? '';
|
|
172
|
+
state.revision++; write(full, state); return summarize(state);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function cli(args) {
|
|
176
|
+
const [command, file, arg, patch] = args;
|
|
177
|
+
if (command === 'init' && args.length === 3) return init(file, readJSON(arg));
|
|
178
|
+
if (command === 'status' && args.length === 2) return status(file);
|
|
179
|
+
if (command === 'next' && args.length === 2) return next(file);
|
|
180
|
+
if (command === 'update' && args.length === 4) return update(file, arg, readJSON(patch));
|
|
181
|
+
throw new Error('Usage: workflow.js init STATE SPEC.json | status STATE | next STATE | update STATE TASK PATCH.json');
|
|
182
|
+
}
|
|
183
|
+
module.exports = { init, load, validate, summarize, status, next, update };
|
|
184
|
+
if (require.main === module) {
|
|
185
|
+
try { process.stdout.write(JSON.stringify(cli(process.argv.slice(2)), null, 2) + '\n'); }
|
|
186
|
+
catch (error) { process.stderr.write(`workflow: ${error.message}\n`); process.exitCode = 1; }
|
|
187
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -48,8 +48,9 @@ STOP and update the wireframe baseline first (a design decision -> `log-decision
|
|
|
48
48
|
When an approved `docs/UX-PACK.md` exists, treat it as the project frame — drift
|
|
49
49
|
from the pack is a defect unless the pack is re-approved.
|
|
50
50
|
When `docs/design/tokens.css` exists, components must use those semantic tokens —
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
unapproved colors / off-grid spacing in UI files are a **BLOCK**. Run
|
|
52
|
+
`node .issflow/scripts/token-lint.js` for unlisted hex/RGB colors; review semantic
|
|
53
|
+
token usage and spacing separately because the script does not verify those. Frontend CLOSE also needs a **task check** for the primary user job.
|
|
53
54
|
|
|
54
55
|
-----
|
|
55
56
|
|