create-issflow 1.9.2 → 1.10.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-issflow",
3
- "version": "1.9.2",
3
+ "version": "1.10.1",
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 };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -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
- const silent = () => process.exit(0);
14
- const deny = (reason) => {
15
- process.stdout.write(JSON.stringify({
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
- // the feature lane carries its own scoped approval and never edits the main PLAN.
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
- const file = String((evt.tool_input || {}).file_path || (evt.tool_input || {}).notebook_path || '');
48
- if (!file) return silent();
49
- const rel = path.relative(projectDir, path.resolve(projectDir, file)).split(path.sep).join('/');
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
- // writable while PENDING: planning + docs + kit + repo meta — never product source.
52
- const ALLOW = [
53
- /^docs\//, /^\.issflow\//, /^\.claude\//, /^\.cursor\//, /^\.github\//, /^\.aider/, /^\.git/,
54
- /^(README|AGENTS|CLAUDE|GEMINI|CHANGELOG|LICENSE)[^/]*$/i, /^[^/]+\.md$/,
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
- deny(
59
- `PLAN-APPROVAL gate (hard rule 13): docs/PLAN.md still reads "> Approval: PENDING", ` +
60
- `so source changes are blocked (attempted: ${rel}). Get the plan signed off first — ` +
61
- `run /overview's PLAN-APPROVAL step (or /replan, then re-approve) and stamp the header ` +
62
- `"> Approval: approved <date> v<n>". Planning artifacts under docs/ stay writable.`
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=ACMRTD', 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.
@@ -18,9 +18,15 @@
18
18
  // own doc + slug. Results come back as commits on feature/<slug> (visible from the
19
19
  // main repo); remove the dir with `git worktree remove` after merge.
20
20
  'use strict';
21
- const fs = require('fs');
22
- const path = require('path');
23
- const { spawnSync } = require('child_process');
21
+ // Dynamic imports work in both CommonJS and ESM project scopes.
22
+ Promise.all([
23
+ import('node:fs'),
24
+ import('node:path'),
25
+ import('node:child_process'),
26
+ ]).then(([fsModule, pathModule, childModule]) => {
27
+ const fs = fsModule.default;
28
+ const path = pathModule.default;
29
+ const { spawnSync } = childModule;
24
30
 
25
31
  const IMAGE = process.env.ISSFLOW_IMAGE || 'issflow-runner';
26
32
  const argv = process.argv.slice(2);
@@ -105,3 +111,7 @@ args.push(IMAGE, '-p', `/feature ${doc}`, '--dangerously-skip-permissions');
105
111
  const res = run('docker', args);
106
112
  if (res.status !== 0) die(`run exited with status ${res.status} — see output above; blockers land in docs/features/<slug>/BLOCKED.md`);
107
113
  console.log('feature-docker: done. Review the feature branch, then run /uat with the TEST-PLAN.');
114
+ }).catch((error) => {
115
+ console.error('feature-docker: ' + error.message);
116
+ process.exitCode = 1;
117
+ });