create-issflow 1.9.2 → 1.10.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/package.json +1 -1
- package/template/.issflow/hooks/approval-policy.js +78 -0
- package/template/.issflow/hooks/plan-gate.js +49 -56
- package/template/.issflow/hooks/verify-approval.js +43 -0
- package/template/.issflow/istartsoft-flow/METHODOLOGY.md +43 -0
- package/template/.issflow/templates/PLAN-SCOPED.md +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-issflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Scaffold the iStartSoftFlow AI-coding workflow into a project. Stack-agnostic, tool-agnostic (Claude Code, Codex, Cursor, Gemini, Aider), non-destructive.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-issflow": "bin/cli.js"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function fail(message) {
|
|
8
|
+
return { ok: false, reason: message };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function within(root, relative) {
|
|
12
|
+
if (!relative || path.isAbsolute(relative)) return false;
|
|
13
|
+
const full = path.resolve(root, relative);
|
|
14
|
+
return full.startsWith(path.resolve(root) + path.sep);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function read(root, relative) {
|
|
18
|
+
if (!within(root, relative)) throw new Error('Path escapes repository');
|
|
19
|
+
return fs.readFileSync(path.join(root, relative), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function gitBlobSha(content) {
|
|
23
|
+
const bytes = Buffer.from(content, 'utf8');
|
|
24
|
+
return crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function activePlan(root, explicit) {
|
|
28
|
+
if (explicit) return explicit.replaceAll('\\', '/');
|
|
29
|
+
const pointer = path.join(root, 'docs', 'ACTIVE-PLAN');
|
|
30
|
+
if (fs.existsSync(pointer)) return fs.readFileSync(pointer, 'utf8').trim();
|
|
31
|
+
const plansDir = path.join(root, 'docs', 'plans');
|
|
32
|
+
if (fs.existsSync(plansDir) && fs.readdirSync(plansDir).some((name) => /^PLAN-.*\.md$/i.test(name))) {
|
|
33
|
+
throw new Error('docs/ACTIVE-PLAN is required when scoped plans exist');
|
|
34
|
+
}
|
|
35
|
+
return 'docs/PLAN.md'; // Legacy projects remain supported.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matchesPath(pattern, relative) {
|
|
39
|
+
if (pattern.endsWith('/**')) return relative.startsWith(pattern.slice(0, -2));
|
|
40
|
+
return relative === pattern;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validate(root, { planFile, changedFiles = [] } = {}) {
|
|
44
|
+
try {
|
|
45
|
+
const relativePlan = activePlan(root, planFile);
|
|
46
|
+
if (!within(root, relativePlan)) return fail('Invalid plan path');
|
|
47
|
+
const plan = read(root, relativePlan);
|
|
48
|
+
if (relativePlan === 'docs/PLAN.md') {
|
|
49
|
+
const match = plan.match(/^>\s*Approval:\s*(.+)$/mi);
|
|
50
|
+
if (!match || !/^approved\b/i.test(match[1].trim())) return fail('Legacy PLAN.md is not approved');
|
|
51
|
+
return { ok: true, mode: 'legacy', planFile: relativePlan, warning: 'Legacy approval is not revision-scoped; migrate to docs/plans/' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const id = plan.match(/^#\s+(PLAN-[A-Za-z0-9-]+)\b/m)?.[1];
|
|
55
|
+
const revision = Number(plan.match(/^>\s*Revision:\s*(\d+)\s*$/mi)?.[1]);
|
|
56
|
+
if (!id || !Number.isSafeInteger(revision) || revision < 1) return fail('Plan ID or revision missing');
|
|
57
|
+
const approvalFile = `docs/approvals/${id}.json`;
|
|
58
|
+
const approval = JSON.parse(read(root, approvalFile));
|
|
59
|
+
if (approval.planId !== id || approval.revision !== revision) return fail('Approval is for a different plan ID or revision');
|
|
60
|
+
if (approval.planBlobSha !== gitBlobSha(plan)) return fail('Plan changed after approval; request a new approval');
|
|
61
|
+
if (!approval.approvedBy || !approval.approvedAt || !approval.approvalSource) return fail('Approval provenance is incomplete');
|
|
62
|
+
if (!Array.isArray(approval.allowedPaths) || approval.allowedPaths.length === 0 ||
|
|
63
|
+
!approval.allowedPaths.every((p) => typeof p === 'string' && p.length > 0 && !p.includes('..') && !p.startsWith('/'))) {
|
|
64
|
+
return fail('Approved path boundaries are missing or invalid');
|
|
65
|
+
}
|
|
66
|
+
for (const file of changedFiles) {
|
|
67
|
+
const normalized = file.replaceAll('\\', '/');
|
|
68
|
+
if (!within(root, normalized) || !approval.allowedPaths.some((p) => matchesPath(p, normalized))) {
|
|
69
|
+
return fail(`File outside approved plan scope: ${file}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, mode: 'scoped', planFile: relativePlan, planId: id, revision, approval };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return fail(`Approval verification failed: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { validate, gitBlobSha, activePlan, matchesPath };
|
|
@@ -1,64 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse plan gate — hard rule 13 as code. While docs/PLAN.md carries
|
|
3
|
-
// `> Approval: PENDING`, no SOURCE file may be created or edited: the plan
|
|
4
|
-
// needs a human sign-off before build work starts. Docs, kit config, and
|
|
5
|
-
// planning artifacts stay writable (planning is exactly what PENDING is for).
|
|
6
|
-
// An active feature lane is exempt — its doc approval is its own rule-13-scoped
|
|
7
|
-
// gate and it never touches the main PLAN. Fail-OPEN on any error: a hook bug
|
|
8
|
-
// must never wedge the tool loop. Pure Node, cross-platform, zero deps.
|
|
9
2
|
'use strict';
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
const path = require('path');
|
|
12
3
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
|
|
17
|
-
}));
|
|
18
|
-
process.exit(0);
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
let input = '';
|
|
22
|
-
process.stdin.setEncoding('utf8');
|
|
23
|
-
process.stdin.on('data', (d) => (input += d));
|
|
24
|
-
process.stdin.on('end', () => {
|
|
25
|
-
let evt;
|
|
26
|
-
try { evt = JSON.parse(input); } catch (_) { return silent(); }
|
|
27
|
-
try { run(evt); } catch (_) { silent(); }
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
function run(evt) {
|
|
31
|
-
const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
32
|
-
if (!MUTATORS.has(evt.tool_name || '')) return silent();
|
|
33
|
-
|
|
34
|
-
const projectDir = process.env.CLAUDE_PROJECT_DIR || evt.cwd || '.';
|
|
35
|
-
const read = (f) => { try { return fs.readFileSync(path.join(projectDir, f), 'utf8'); } catch (_) { return null; } };
|
|
36
|
-
|
|
37
|
-
const plan = read(path.join('docs', 'PLAN.md'));
|
|
38
|
-
if (!plan) return silent(); // no plan world (e.g. /quick-only repo)
|
|
39
|
-
const approval = plan.match(/^>\s*Approval:\s*(.+)$/m);
|
|
40
|
-
if (!approval) return silent(); // not our PLAN format — don't guess
|
|
41
|
-
if (!/pending/i.test(approval[1])) return silent(); // signed off — build away
|
|
4
|
+
// Claude PreToolUse guard. Shell commands and other hosts require the CLI/CI verifier.
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { validate } = require('./approval-policy');
|
|
42
7
|
|
|
43
|
-
|
|
44
|
-
const state = read(path.join('docs', 'STATE.md')) || '';
|
|
45
|
-
if (/^\s*feature:\s*\S+\s*\(active\)/m.test(state)) return silent();
|
|
8
|
+
const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
46
9
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
10
|
+
function deny(reason) {
|
|
11
|
+
process.stdout.write(JSON.stringify({
|
|
12
|
+
hookSpecificOutput: {
|
|
13
|
+
hookEventName: 'PreToolUse',
|
|
14
|
+
permissionDecision: 'deny',
|
|
15
|
+
permissionDecisionReason: reason,
|
|
16
|
+
},
|
|
17
|
+
}) + '\n');
|
|
18
|
+
}
|
|
50
19
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/^docs
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (ALLOW.some((re) => re.test(rel))) return silent();
|
|
20
|
+
function safePlanningPath(relative) {
|
|
21
|
+
return /^docs\/plans\/PLAN-[\w-]+\.md$/.test(relative) ||
|
|
22
|
+
/^docs\/features\/[\w.-]+\/(PLAN|FEATURE)\.md$/.test(relative) ||
|
|
23
|
+
relative === 'docs/ACTIVE-PLAN';
|
|
24
|
+
}
|
|
57
25
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
26
|
+
function run(event) {
|
|
27
|
+
if (!MUTATORS.has(event.tool_name)) return;
|
|
28
|
+
const root = path.resolve(process.env.CLAUDE_PROJECT_DIR || event.cwd || process.cwd());
|
|
29
|
+
const input = event.tool_input || {};
|
|
30
|
+
const paths = event.tool_name === 'MultiEdit'
|
|
31
|
+
? [input.file_path]
|
|
32
|
+
: [input.file_path || input.notebook_path];
|
|
33
|
+
if (paths.some((p) => typeof p !== 'string' || !p.trim())) {
|
|
34
|
+
return deny('Approval gate cannot determine target path');
|
|
35
|
+
}
|
|
36
|
+
const relative = paths.map((p) => path.relative(root, path.resolve(root, p)).split(path.sep).join('/'));
|
|
37
|
+
if (relative.some((p) => p.startsWith('..') || path.isAbsolute(p))) {
|
|
38
|
+
return deny('Cannot edit outside repository');
|
|
39
|
+
}
|
|
40
|
+
if (relative.every(safePlanningPath)) return;
|
|
41
|
+
if (relative.some((p) => /^docs\/approvals\//.test(p))) {
|
|
42
|
+
return deny('Approval records require a separate owner-controlled action');
|
|
43
|
+
}
|
|
44
|
+
const result = validate(root, { changedFiles: relative });
|
|
45
|
+
if (!result.ok) deny(`Plan approval required: ${result.reason}`);
|
|
64
46
|
}
|
|
47
|
+
|
|
48
|
+
let raw = '';
|
|
49
|
+
process.stdin.setEncoding('utf8');
|
|
50
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
51
|
+
process.stdin.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
run(JSON.parse(raw));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
deny(`Approval gate error: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { validate } = require('./approval-policy');
|
|
7
|
+
|
|
8
|
+
function main(args) {
|
|
9
|
+
const root = process.cwd();
|
|
10
|
+
const baseIndex = args.indexOf('--base');
|
|
11
|
+
const filesIndex = args.indexOf('--files');
|
|
12
|
+
const planIndex = args.indexOf('--plan');
|
|
13
|
+
if (baseIndex !== -1 && filesIndex !== -1) throw new Error('Choose --base or --files');
|
|
14
|
+
const planFile = planIndex < 0 ? undefined : args[planIndex + 1];
|
|
15
|
+
let changedFiles = [];
|
|
16
|
+
if (baseIndex >= 0) {
|
|
17
|
+
const base = args[baseIndex + 1];
|
|
18
|
+
if (!base || base.startsWith('--')) throw new Error('Missing --base value');
|
|
19
|
+
const mergeBase = execFileSync('git', ['merge-base', base, 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
|
|
20
|
+
const diff = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMRT', mergeBase, 'HEAD'], { cwd: root, encoding: 'utf8' });
|
|
21
|
+
changedFiles = diff.split(/\r?\n/).filter(Boolean);
|
|
22
|
+
} else if (filesIndex >= 0) {
|
|
23
|
+
const files = args[filesIndex + 1];
|
|
24
|
+
if (!files || files.startsWith('--')) throw new Error('Missing --files value');
|
|
25
|
+
changedFiles = files.split(',').map((f) => f.trim()).filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
const result = validate(root, { planFile, changedFiles });
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
console.error(`BLOCKED: ${result.reason}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log(`Approval verified: ${result.planId || 'legacy'}${result.revision ? ` revision ${result.revision}` : ''}`);
|
|
34
|
+
if (result.warning) console.warn(result.warning);
|
|
35
|
+
console.log(`Checked ${changedFiles.length} changed file(s)`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
main(process.argv.slice(2));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`BLOCKED: ${error.message}`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
@@ -44,6 +44,49 @@ coding task.
|
|
|
44
44
|
|
|
45
45
|
-----
|
|
46
46
|
|
|
47
|
+
## Plan-scoped approvals (new work)
|
|
48
|
+
|
|
49
|
+
For each new task, create one `docs/plans/PLAN-<id>.md` with a unique ID, revision,
|
|
50
|
+
scope, changed-file boundaries, acceptance criteria, tests, and risks. Set
|
|
51
|
+
`docs/ACTIVE-PLAN` to its relative path. One human approval authorizes exactly
|
|
52
|
+
one plan revision, not other plans or future edits to that plan.
|
|
53
|
+
|
|
54
|
+
Approval is recorded separately in `docs/approvals/PLAN-<id>.json` with
|
|
55
|
+
`planId`, `revision`, `planBlobSha` (Git blob SHA-1 of the exact Markdown),
|
|
56
|
+
`approvedBy`, `approvedAt`, `approvalSource`, and `allowedPaths`.
|
|
57
|
+
The approval record is created only after explicit human consent. Revisions,
|
|
58
|
+
including seemingly small edits, invalidate the old digest and require new approval.
|
|
59
|
+
Do not let an agent self-approve a plan.
|
|
60
|
+
|
|
61
|
+
Use `node .issflow/hooks/verify-approval.js --base origin/main` to check the
|
|
62
|
+
active plan and changed paths. Run `node --test tests/approval-policy.test.js`
|
|
63
|
+
when those tests are installed. For an existing project with only `docs/PLAN.md`,
|
|
64
|
+
the verifier retains legacy `> Approval: approved ...` compatibility and warns
|
|
65
|
+
that it is not revision-scoped. Migrate intentionally, not by silently rewriting
|
|
66
|
+
existing plans.
|
|
67
|
+
|
|
68
|
+
**Enforcement boundary:** The Claude PreToolUse hook protects supported file-edit
|
|
69
|
+
tools; it does not intercept shell commands, GitHub API writes, or every Codex
|
|
70
|
+
execution mode. The verifier checks the local checkout/PR diff, but a writable
|
|
71
|
+
approval JSON is not cryptographic proof of the human approver. Require protected
|
|
72
|
+
branches, required CI checks, and an actual human GitHub PR review for merge.
|
|
73
|
+
No tool can treat a self-edited approval file as owner authorization.
|
|
74
|
+
|
|
75
|
+
**Recovery:** If approval metadata is missing or malformed, stop implementation.
|
|
76
|
+
A human should inspect the plan, restore the correct pointer and approval record,
|
|
77
|
+
then rerun verification. Never bypass the gate by editing `STATE.md` or disabling
|
|
78
|
+
checks. A scoped feature lane does not inherit approval from another plan.
|
|
79
|
+
|
|
80
|
+
**Engineering:** Prefer feature-based modules and vertical slices. Keep route
|
|
81
|
+
adapters thin; put business rules near their feature. Use idiomatic names,
|
|
82
|
+
cohesive files, explicit contracts, and minimal abstractions. Comments explain
|
|
83
|
+
non-obvious intent, not line-by-line mechanics. Use concise Conventional Commit
|
|
84
|
+
subjects (`feat(scope): change`, `fix(scope): change`); avoid tool signatures
|
|
85
|
+
and promotional boilerplate while preserving truthful Git author/committer
|
|
86
|
+
metadata and any required disclosures.
|
|
87
|
+
|
|
88
|
+
-----
|
|
89
|
+
|
|
47
90
|
## The loop
|
|
48
91
|
|
|
49
92
|
`design-research -> grill (×2) -> plan -> implement -> test -> deploy`, one
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PLAN-XXXX — Task title
|
|
2
|
+
|
|
3
|
+
> Revision: 1
|
|
4
|
+
> Status: PENDING
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
Describe the desired outcome.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
- In scope:
|
|
11
|
+
- Out of scope:
|
|
12
|
+
|
|
13
|
+
## Changed-file boundaries
|
|
14
|
+
List the paths that the owner will approve in the approval record.
|
|
15
|
+
|
|
16
|
+
## Approach
|
|
17
|
+
1. Research the current implementation.
|
|
18
|
+
2. Implement one vertical slice.
|
|
19
|
+
3. Verify and review the diff.
|
|
20
|
+
|
|
21
|
+
## Acceptance criteria
|
|
22
|
+
- [ ] Observable behavior with test evidence.
|
|
23
|
+
|
|
24
|
+
## Tests
|
|
25
|
+
- Unit:
|
|
26
|
+
- Integration:
|
|
27
|
+
- Browser/manual:
|
|
28
|
+
|
|
29
|
+
## Risks and rollback
|
|
30
|
+
- Risks:
|
|
31
|
+
- Recovery:
|
|
32
|
+
|
|
33
|
+
## Owner approval
|
|
34
|
+
Do not change source until the owner approves this exact plan revision.
|
|
35
|
+
The approval record must bind the ID, revision, and Git blob hash.
|
|
36
|
+
Merge and deployment require separate authorization.
|