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.
@@ -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
- raw hex / off-grid spacing in UI files is a **BLOCK** (run `node scripts/token-lint.js`
52
- when present). Frontend CLOSE also needs a **task check** for the primary user job.
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