create-issflow 1.10.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 +49 -22
- package/package.json +1 -1
- package/template/.issflow/README.md +8 -2
- package/template/.issflow/agents/planner.md +13 -3
- package/template/.issflow/agents/synthesizer.md +17 -8
- package/template/.issflow/commands/change-request.md +10 -2
- package/template/.issflow/commands/design.md +35 -20
- package/template/.issflow/commands/feature.md +33 -13
- package/template/.issflow/commands/goal.md +17 -8
- package/template/.issflow/commands/overview.md +46 -7
- package/template/.issflow/commands/phase.md +99 -32
- package/template/.issflow/commands/propose.md +25 -7
- package/template/.issflow/commands/quick.md +16 -4
- package/template/.issflow/commands/sprint.md +21 -12
- package/template/.issflow/commands/synthesize.md +10 -6
- package/template/.issflow/flow-config.json +11 -4
- package/template/.issflow/hooks/pre-compact.js +4 -0
- package/template/.issflow/hooks/session-start.js +84 -123
- package/template/.issflow/istartsoft-flow/METHODOLOGY.md +186 -46
- package/template/.issflow/models/MODEL-MATRIX.md +33 -0
- package/template/.issflow/routing/model-router.js +66 -0
- package/template/.issflow/routing/package.json +1 -0
- package/template/.issflow/routing/policy.json +69 -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/entry-notice.js +20 -0
- package/template/.issflow/scripts/package.json +1 -0
- package/template/.issflow/scripts/token-lint.js +58 -0
- package/template/.issflow/skills/ui-ux-pro-max/SKILL.md +7 -2
- package/template/.issflow/skills/ux-design/SKILL.md +4 -0
- package/template/.issflow/templates/ARCHITECTURE-template.md +7 -3
- package/template/.issflow/templates/PLAN-SCOPED.md +12 -0
- package/template/.issflow/templates/UX-PACK-template.md +19 -8
- package/template/.issflow/templates/design/DIRECTION.md +22 -0
- package/template/.issflow/templates/design/inventory.md +18 -0
- package/template/.issflow/templates/design/picker.html +165 -0
- package/template/.issflow/templates/design/platforms/line-oa.md +16 -0
- package/template/.issflow/templates/design/platforms/mobile-app.md +10 -0
- package/template/.issflow/templates/design/platforms/pwa.md +11 -0
- package/template/.issflow/templates/design/platforms/web-responsive.md +9 -0
- package/template/.issflow/templates/design/screens/SCREEN.md +19 -0
- package/template/.issflow/templates/design/tokens.css +47 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Model matrix (guidance)
|
|
2
|
+
|
|
3
|
+
**as_of:** 2026-11-04
|
|
4
|
+
Update this file when vendors ship new models. Do **not** hardcode unverified API IDs into agent runners.
|
|
5
|
+
|
|
6
|
+
Agent frontmatter stays: `haiku` · `sonnet` · `opus` · `inherit`.
|
|
7
|
+
Adaptive router (`.issflow/routing/`) remains advisory / fail-closed.
|
|
8
|
+
|
|
9
|
+
## Capability tiers → host suggestions
|
|
10
|
+
|
|
11
|
+
| Kit alias / role band | Claude Code | Cursor | Codex CLI | Antigravity |
|
|
12
|
+
|----------------------|-------------|--------|-----------|-------------|
|
|
13
|
+
| `haiku` (synthesizer, cheap) | Haiku 5.5 alias `haiku` | Composer 2.5 / Auto | Luna-class (plan-dependent) | Gemini 3.8 Flash |
|
|
14
|
+
| `sonnet` (researcher, e2e, daily) | Sonnet 5.5 alias `sonnet` | Composer 2.5 / Auto / Sonnet | Terra-class | Gemini 3.8 Flash |
|
|
15
|
+
| `opus` / `inherit` hard (planner, debugger, arch) | Opus 5.5 / session model | Opus / Sol / Grok flagship | Sol-class | Gemini 4 Argon |
|
|
16
|
+
| Long-horizon session | Fable / `opus[1m]` | Grok flagship | Sol + high effort | Argon High |
|
|
17
|
+
|
|
18
|
+
## Role defaults (unchanged aliases)
|
|
19
|
+
|
|
20
|
+
| Role | `model:` |
|
|
21
|
+
|------|----------|
|
|
22
|
+
| synthesizer | `haiku` |
|
|
23
|
+
| researcher · e2e-runner | `sonnet` |
|
|
24
|
+
| planner · debugger · implementer · test-author | `inherit` |
|
|
25
|
+
|
|
26
|
+
## Verify before pinning IDs
|
|
27
|
+
|
|
28
|
+
- Claude: https://code.claude.com/docs/en/model-config
|
|
29
|
+
- Cursor: https://cursor.com/help/models-and-usage/available-models
|
|
30
|
+
- Codex: https://learn.chatgpt.com/docs/models
|
|
31
|
+
- Antigravity: https://antigravity.google/docs/models/
|
|
32
|
+
|
|
33
|
+
If a host cannot switch models per agent, run everything on the session model.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Host-aware model routing. Pure decision logic; does not alter approvals,
|
|
5
|
+
* mutate host configuration, or assume UI labels are model identifiers.
|
|
6
|
+
* Hosts must inject verified capabilities and a switchModel implementation.
|
|
7
|
+
*/
|
|
8
|
+
const policy = require('./policy.json');
|
|
9
|
+
const SUPPORTED = Object.freeze(['codex', 'claude', 'cursor']);
|
|
10
|
+
const TIERS = Object.freeze(['economy', 'balanced', 'advanced']);
|
|
11
|
+
const HIGH_RISK = new Set(['security', 'authentication', 'authorization', 'payments', 'secrets', 'production', 'migration']);
|
|
12
|
+
|
|
13
|
+
function route(input = {}) {
|
|
14
|
+
const { provider, agent = 'orchestrator', task = '', risk = 'low', complexity = 'medium',
|
|
15
|
+
mode = policy.defaultMode, capabilities = {} } = input;
|
|
16
|
+
if (!SUPPORTED.includes(provider)) throw new Error('Unsupported provider: ' + String(provider));
|
|
17
|
+
if (!['advisory', 'automatic'].includes(mode)) throw new Error('Invalid routing mode');
|
|
18
|
+
const elevated = risk === 'high' || HIGH_RISK.has(String(task).toLowerCase());
|
|
19
|
+
let tier = policy.agents[agent] || 'balanced';
|
|
20
|
+
if (elevated || complexity === 'high') tier = 'advanced';
|
|
21
|
+
if (!TIERS.includes(tier)) tier = 'balanced';
|
|
22
|
+
|
|
23
|
+
const preference = policy.providers[provider][tier];
|
|
24
|
+
const verified = Array.isArray(capabilities.models) ? capabilities.models : [];
|
|
25
|
+
// Never invent model IDs: only an explicitly configured, verified model is selectable.
|
|
26
|
+
const configured = capabilities.tiers && capabilities.tiers[tier];
|
|
27
|
+
const modelId = configured && configured.modelId || preference.modelId;
|
|
28
|
+
const match = verified.find(m => m.id === modelId);
|
|
29
|
+
const requestedEffort = configured && configured.reasoningEffort !== undefined
|
|
30
|
+
? configured.reasoningEffort : preference.reasoningEffort;
|
|
31
|
+
const supportedEfforts = match && Array.isArray(match.reasoningEfforts) ? match.reasoningEfforts : [];
|
|
32
|
+
const effort = requestedEffort && supportedEfforts.includes(requestedEffort) ? requestedEffort : null;
|
|
33
|
+
const eligible = !!match && (!requestedEffort || !!effort);
|
|
34
|
+
return {
|
|
35
|
+
provider, agent, tier, mode, modelId: eligible ? modelId : null,
|
|
36
|
+
reasoningEffort: eligible ? effort : null,
|
|
37
|
+
availability: eligible ? 'verified' : 'unknown',
|
|
38
|
+
action: eligible ? (mode === 'automatic' ? 'switch-request' : 'recommend') : 'inherit',
|
|
39
|
+
reason: elevated ? 'Elevated risk' : complexity === 'high' ? 'High complexity' : 'Agent default',
|
|
40
|
+
fallback: 'inherit',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Automatic mode is opt-in. switchModel must be a host-provided function
|
|
46
|
+
* returning {confirmed:true, modelId, reasoningEffort}; a request alone is
|
|
47
|
+
* never considered a successful model switch.
|
|
48
|
+
*/
|
|
49
|
+
async function execute(input = {}, host = {}) {
|
|
50
|
+
const decision = route(input);
|
|
51
|
+
if (decision.action !== 'switch-request') return { ...decision, switched: false };
|
|
52
|
+
if (host.supportsSwitch !== true || typeof host.switchModel !== 'function') {
|
|
53
|
+
return { ...decision, action: 'recommend', switched: false, reason: 'Host switch unavailable' };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const result = await host.switchModel({ modelId: decision.modelId, reasoningEffort: decision.reasoningEffort });
|
|
57
|
+
const confirmed = result && result.confirmed === true && result.modelId === decision.modelId &&
|
|
58
|
+
(decision.reasoningEffort === null || result.reasoningEffort === decision.reasoningEffort);
|
|
59
|
+
return { ...decision, action: confirmed ? 'switched' : 'recommend', switched: !!confirmed,
|
|
60
|
+
reason: confirmed ? decision.reason : 'Host did not confirm selected model/effort' };
|
|
61
|
+
} catch (_) {
|
|
62
|
+
return { ...decision, action: 'recommend', switched: false, reason: 'Host switch failed' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { route, execute, policy };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"defaultMode": "advisory",
|
|
4
|
+
"tiers": {
|
|
5
|
+
"economy": {
|
|
6
|
+
"description": "Low-risk documentation and routine checks"
|
|
7
|
+
},
|
|
8
|
+
"balanced": {
|
|
9
|
+
"description": "Implementation and test work"
|
|
10
|
+
},
|
|
11
|
+
"advanced": {
|
|
12
|
+
"description": "Architecture, security and complex debugging"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"agents": {
|
|
16
|
+
"orchestrator": "balanced",
|
|
17
|
+
"researcher": "economy",
|
|
18
|
+
"planner": "advanced",
|
|
19
|
+
"implementer": "balanced",
|
|
20
|
+
"test-author": "balanced",
|
|
21
|
+
"debugger": "balanced",
|
|
22
|
+
"e2e-runner": "economy",
|
|
23
|
+
"synthesizer": "economy"
|
|
24
|
+
},
|
|
25
|
+
"providers": {
|
|
26
|
+
"codex": {
|
|
27
|
+
"economy": {
|
|
28
|
+
"modelId": null,
|
|
29
|
+
"reasoningEffort": "medium"
|
|
30
|
+
},
|
|
31
|
+
"balanced": {
|
|
32
|
+
"modelId": null,
|
|
33
|
+
"reasoningEffort": "high"
|
|
34
|
+
},
|
|
35
|
+
"advanced": {
|
|
36
|
+
"modelId": null,
|
|
37
|
+
"reasoningEffort": "xhigh"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"claude": {
|
|
41
|
+
"economy": {
|
|
42
|
+
"modelId": null,
|
|
43
|
+
"reasoningEffort": null
|
|
44
|
+
},
|
|
45
|
+
"balanced": {
|
|
46
|
+
"modelId": null,
|
|
47
|
+
"reasoningEffort": null
|
|
48
|
+
},
|
|
49
|
+
"advanced": {
|
|
50
|
+
"modelId": null,
|
|
51
|
+
"reasoningEffort": null
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"cursor": {
|
|
55
|
+
"economy": {
|
|
56
|
+
"modelId": null,
|
|
57
|
+
"reasoningEffort": null
|
|
58
|
+
},
|
|
59
|
+
"balanced": {
|
|
60
|
+
"modelId": null,
|
|
61
|
+
"reasoningEffort": null
|
|
62
|
+
},
|
|
63
|
+
"advanced": {
|
|
64
|
+
"modelId": null,
|
|
65
|
+
"reasoningEffort": null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Host capabilities and verification
|
|
2
|
+
|
|
3
|
+
The installer ships procedures plus local Node helpers. It does not install an
|
|
4
|
+
agent service, guarantee unattended continuation, or enable persistent access.
|
|
5
|
+
An active host agent must perform the work and call the helper. Future sessions
|
|
6
|
+
require a separately authorized host scheduler. A checkpoint is not approval.
|
|
7
|
+
|
|
8
|
+
| Capability | Claude adapter | Codex adapter | Cursor adapter |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| Entry instructions | CLAUDE.md imports AGENTS.md | AGENTS.md | always-apply workflow/gate rules + AGENTS.md |
|
|
11
|
+
| Roles | .claude agent mirror, host-dependent discovery | reference files; use native delegation if exposed | reference files; use native delegation if exposed |
|
|
12
|
+
| Installed hooks | session, pre-tool, pre-compact, subagent-stop, stop | none | sessionStart, subagentStop only |
|
|
13
|
+
| Task state helper | explicit Node CLI | explicit Node CLI | explicit Node CLI |
|
|
14
|
+
| Checkpoint recovery | lean Tier-A session hook summarizes state; agent reconciles | agent reads state explicitly | session hook if supported; otherwise explicit |
|
|
15
|
+
| Approval enforcement | edit hook covers listed edit tools, not all shell writes | explicit verifier / CI | explicit verifier / CI |
|
|
16
|
+
| Completion proof | agent checks current code; file gate alone is insufficient | same | same |
|
|
17
|
+
|
|
18
|
+
Host versions and permissions vary. Detect available tools at session start.
|
|
19
|
+
Never claim a role file creates an isolated worker or a hook covers every edit.
|
|
20
|
+
If delegation is absent, use serial roles. If a hook is absent, perform the same
|
|
21
|
+
ritual explicitly. Respect host safety, approval, token, and execution limits.
|
|
22
|
+
|
|
23
|
+
## Smoke checks
|
|
24
|
+
|
|
25
|
+
Automated installer tests cover Claude/Codex/Cursor in CommonJS and ESM projects,
|
|
26
|
+
dry-run, runtime loading, non-destructive upgrades, and truthful adapter text.
|
|
27
|
+
Runtime tests exercise restart, blocked dependencies, independent tasks, budgets,
|
|
28
|
+
retries, and evidence. These are local contract tests, not live vendor-host tests.
|
|
29
|
+
|
|
30
|
+
For a real host smoke check, use an isolated disposable project:
|
|
31
|
+
1. Install its adapter and inspect generated entry files/hooks.
|
|
32
|
+
2. Start a fresh session, verify instructions are loaded and note actual tools.
|
|
33
|
+
3. Run an authorized tiny task and record command output with code revision.
|
|
34
|
+
4. Interrupt after a checkpoint. Start a new session and reconcile running work
|
|
35
|
+
rather than blindly retrying a possibly completed external action.
|
|
36
|
+
5. Block one task and confirm independent authorized work continues.
|
|
37
|
+
6. Verify the final report distinguishes passed, failed, blocked and unrun checks.
|
|
38
|
+
|
|
39
|
+
Do not count these live-host checks as passed until they actually run. Publishing,
|
|
40
|
+
PR creation and deployment need their own authorized scope; local tests do not
|
|
41
|
+
imply remote publication or CI success.
|
|
@@ -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,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Repo-internal PostToolUse hook (NOT shipped by create-issflow — build.js only
|
|
3
|
+
// copies agents/commands/skills/hooks/istartsoft-flow/templates + README).
|
|
4
|
+
// When root entry shims are edited, nudge to keep AGENTS.md / host adapters aligned.
|
|
5
|
+
'use strict';
|
|
6
|
+
let data = '';
|
|
7
|
+
process.stdin.on('data', (c) => (data += c)).on('end', () => {
|
|
8
|
+
try {
|
|
9
|
+
const f = (JSON.parse(data || '{}').tool_input || {}).file_path || '';
|
|
10
|
+
if (/(^|[\\/])CLAUDE\.md$/.test(f)) {
|
|
11
|
+
process.stdout.write(JSON.stringify({
|
|
12
|
+
systemMessage: 'CLAUDE.md is a host shim — keep `@AGENTS.md` as the only SoT pointer. Sync BMAD if needed: save_artifact type=claude-md',
|
|
13
|
+
}));
|
|
14
|
+
} else if (/(^|[\\/])AGENTS\.md$/.test(f)) {
|
|
15
|
+
process.stdout.write(JSON.stringify({
|
|
16
|
+
systemMessage: 'AGENTS.md changed — keep it an index of `.issflow/istartsoft-flow/METHODOLOGY.md` (anti-drift: do not paste hard rules here).',
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
} catch (_) { /* ignore malformed hook input */ }
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Portable copy — prefer project `scripts/token-lint.js` after install.
|
|
3
|
+
// Same logic as repo-root scripts/token-lint.js for kit consumers.
|
|
4
|
+
'use strict';
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const ROOT = process.cwd();
|
|
9
|
+
const TOKENS = path.join(ROOT, 'docs', 'design', 'tokens.css');
|
|
10
|
+
const SCAN_GLOBS = ['src', 'app', 'components', 'pages', 'web', 'frontend', 'ui'];
|
|
11
|
+
const EXT = new Set(['.css', '.scss', '.tsx', '.jsx', '.vue', '.svelte', '.html']);
|
|
12
|
+
const HEX = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
13
|
+
const RGB = /\brgba?\([^)]+\)/g;
|
|
14
|
+
|
|
15
|
+
function walk(dir, out = []) {
|
|
16
|
+
if (!fs.existsSync(dir)) return out;
|
|
17
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
|
|
19
|
+
const p = path.join(dir, e.name);
|
|
20
|
+
if (e.isDirectory()) walk(p, out);
|
|
21
|
+
else if (EXT.has(path.extname(e.name))) out.push(p);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(TOKENS)) {
|
|
27
|
+
console.log('token-lint: skip (no docs/design/tokens.css)');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tokenSrc = fs.readFileSync(TOKENS, 'utf8');
|
|
32
|
+
const allowed = new Set();
|
|
33
|
+
for (const m of tokenSrc.matchAll(HEX)) allowed.add(m[0].toLowerCase());
|
|
34
|
+
for (const m of tokenSrc.matchAll(RGB)) allowed.add(m[0].replace(/\s+/g, '').toLowerCase());
|
|
35
|
+
|
|
36
|
+
const files = SCAN_GLOBS.flatMap((d) => walk(path.join(ROOT, d)));
|
|
37
|
+
const hits = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
if (path.resolve(file) === path.resolve(TOKENS)) continue;
|
|
40
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
41
|
+
const rel = path.relative(ROOT, file);
|
|
42
|
+
for (const m of text.matchAll(HEX)) {
|
|
43
|
+
if (!allowed.has(m[0].toLowerCase())) hits.push(`${rel}: raw hex ${m[0]}`);
|
|
44
|
+
}
|
|
45
|
+
for (const m of text.matchAll(RGB)) {
|
|
46
|
+
const norm = m[0].replace(/\s+/g, '').toLowerCase();
|
|
47
|
+
if (!allowed.has(norm)) hits.push(`${rel}: raw color ${m[0]}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!hits.length) {
|
|
52
|
+
console.log(`token-lint: ok (${files.length} files)`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
console.error('token-lint: BLOCK — use semantic tokens from docs/design/tokens.css');
|
|
56
|
+
for (const h of hits.slice(0, 40)) console.error(' ' + h);
|
|
57
|
+
if (hits.length > 40) console.error(` … +${hits.length - 40} more`);
|
|
58
|
+
process.exit(1);
|
|
@@ -66,5 +66,10 @@ python3 .issflow/skills/ui-ux-pro-max/scripts/search.py "<query>" --stack <detec
|
|
|
66
66
|
4. Then apply `ux-design` authoring (wireframe + cookbook). Catalogue must still
|
|
67
67
|
pass: no emoji icons, tokens not raw hex in components, WCAG AA.
|
|
68
68
|
|
|
69
|
-
If UX-PACK is already **approved**,
|
|
70
|
-
change the
|
|
69
|
+
If UX-PACK is already **approved**, or STATE `design-direction:` is **locked**,
|
|
70
|
+
read the locked DIRECTION/tokens and stop — unless the human asked to change the
|
|
71
|
+
look (then re-picker + re-approve). Never re-roll styles at `/propose` appendix or
|
|
72
|
+
`/phase` CLOSE.
|
|
73
|
+
|
|
74
|
+
Direction Picker: fill `.issflow/templates/design/picker.html` placeholders only;
|
|
75
|
+
do not rewrite the template chrome.
|