shield-harness 1.0.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.
Files changed (39) hide show
  1. package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
  2. package/.claude/hooks/lib/sh-utils.js +241 -0
  3. package/.claude/hooks/lint-on-save.js +240 -0
  4. package/.claude/hooks/sh-circuit-breaker.js +111 -0
  5. package/.claude/hooks/sh-config-guard.js +252 -0
  6. package/.claude/hooks/sh-data-boundary.js +315 -0
  7. package/.claude/hooks/sh-dep-audit.js +101 -0
  8. package/.claude/hooks/sh-elicitation.js +241 -0
  9. package/.claude/hooks/sh-evidence.js +193 -0
  10. package/.claude/hooks/sh-gate.js +330 -0
  11. package/.claude/hooks/sh-injection-guard.js +165 -0
  12. package/.claude/hooks/sh-instructions.js +210 -0
  13. package/.claude/hooks/sh-output-control.js +183 -0
  14. package/.claude/hooks/sh-permission-learn.js +223 -0
  15. package/.claude/hooks/sh-permission.js +157 -0
  16. package/.claude/hooks/sh-pipeline.js +639 -0
  17. package/.claude/hooks/sh-postcompact.js +173 -0
  18. package/.claude/hooks/sh-precompact.js +114 -0
  19. package/.claude/hooks/sh-quiet-inject.js +147 -0
  20. package/.claude/hooks/sh-session-end.js +143 -0
  21. package/.claude/hooks/sh-session-start.js +196 -0
  22. package/.claude/hooks/sh-subagent.js +86 -0
  23. package/.claude/hooks/sh-task-gate.js +138 -0
  24. package/.claude/hooks/sh-user-prompt.js +181 -0
  25. package/.claude/hooks/sh-worktree.js +227 -0
  26. package/.claude/patterns/injection-patterns.json +137 -0
  27. package/.claude/rules/binding-governance.md +62 -0
  28. package/.claude/rules/channel-security.md +90 -0
  29. package/.claude/rules/coding-principles.md +79 -0
  30. package/.claude/rules/dev-environment.md +37 -0
  31. package/.claude/rules/implementation-context.md +112 -0
  32. package/.claude/rules/language.md +26 -0
  33. package/.claude/rules/security.md +109 -0
  34. package/.claude/rules/testing.md +43 -0
  35. package/LICENSE +21 -0
  36. package/README.ja.md +107 -0
  37. package/README.md +105 -0
  38. package/bin/shield-harness.js +141 -0
  39. package/package.json +33 -0
@@ -0,0 +1,639 @@
1
+ #!/usr/bin/env node
2
+ // sh-pipeline.js — STG gate-driven pipeline (Node.js port)
3
+ // Spec: DETAILED_DESIGN.md §8.1
4
+ // Event: TaskCompleted
5
+ // Execution order: after sh-task-gate.js
6
+ // Target response time: < 30000ms
7
+ "use strict";
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { execSync } = require("child_process");
12
+ const {
13
+ readHookInput,
14
+ allow,
15
+ deny,
16
+ readSession,
17
+ writeSession,
18
+ readYaml,
19
+ appendEvidence,
20
+ SH_DIR,
21
+ } = require("./lib/sh-utils");
22
+
23
+ const HOOK_NAME = "sh-pipeline";
24
+ const PIPELINE_CONFIG = path.join(SH_DIR, "config", "pipeline-config.json");
25
+ const BACKLOG_FILE = path.join("tasks", "backlog.yaml");
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Load pipeline configuration.
33
+ * @returns {Object|null}
34
+ */
35
+ function loadPipelineConfig() {
36
+ try {
37
+ if (!fs.existsSync(PIPELINE_CONFIG)) return null;
38
+ return JSON.parse(fs.readFileSync(PIPELINE_CONFIG, "utf8"));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get task data from backlog.yaml.
46
+ * @param {string} taskId
47
+ * @returns {{ stage_status: string, intent: string, branch: string, pr_url: string }|null}
48
+ */
49
+ function getTaskData(taskId) {
50
+ try {
51
+ const backlog = readYaml(BACKLOG_FILE);
52
+ const tasks = backlog.tasks || [];
53
+ const task = tasks.find((t) => t.id === taskId);
54
+ if (!task) return null;
55
+ return {
56
+ stage_status: task.stage_status || null,
57
+ intent: task.intent || "",
58
+ branch: task.branch || "",
59
+ pr_url: task.pr_url || "",
60
+ };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Execute a trusted git operation in a child process.
68
+ * Uses SH_PIPELINE=1 env to identify trusted operations.
69
+ * @param {string} taskId
70
+ * @param {string} command
71
+ * @returns {string} stdout
72
+ */
73
+ function executeTrusted(taskId, command) {
74
+ return execSync(command, {
75
+ encoding: "utf8",
76
+ timeout: 30000,
77
+ env: {
78
+ ...process.env,
79
+ SH_PIPELINE: "1",
80
+ SH_TASK_ID: taskId,
81
+ },
82
+ stdio: ["pipe", "pipe", "pipe"],
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Update backlog.yaml task fields via js-yaml.
88
+ * @param {string} taskId
89
+ * @param {Object} updates - key-value pairs to update
90
+ */
91
+ function updateBacklog(taskId, updates) {
92
+ let yaml;
93
+ try {
94
+ yaml = require("js-yaml");
95
+ } catch {
96
+ // js-yaml not available — skip backlog update
97
+ return;
98
+ }
99
+
100
+ try {
101
+ const content = fs.readFileSync(BACKLOG_FILE, "utf8");
102
+ const backlog = yaml.load(content);
103
+ const tasks = backlog.tasks || [];
104
+ const task = tasks.find((t) => t.id === taskId);
105
+ if (!task) return;
106
+
107
+ // Apply updates
108
+ for (const [key, value] of Object.entries(updates)) {
109
+ if (key === "stg_history_push") {
110
+ if (!Array.isArray(task.stg_history)) task.stg_history = [];
111
+ task.stg_history.push(value);
112
+ } else {
113
+ task[key] = value;
114
+ }
115
+ }
116
+
117
+ // Write back
118
+ const output = yaml.dump(backlog, {
119
+ lineWidth: -1,
120
+ noRefs: true,
121
+ quotingType: '"',
122
+ forceQuotes: false,
123
+ });
124
+ fs.writeFileSync(BACKLOG_FILE, output);
125
+ } catch {
126
+ // Backlog update failure is non-critical for pipeline
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Format commit message from template.
132
+ * @param {string} template
133
+ * @param {string} taskId
134
+ * @param {string} gate
135
+ * @param {string} intent
136
+ * @returns {string}
137
+ */
138
+ function formatCommitMsg(template, taskId, gate, intent) {
139
+ return template
140
+ .replace("{task_id}", taskId)
141
+ .replace("{gate}", gate)
142
+ .replace("{intent}", intent);
143
+ }
144
+
145
+ /**
146
+ * Check if a command exists.
147
+ * @param {string} cmd
148
+ * @returns {boolean}
149
+ */
150
+ function commandExists(cmd) {
151
+ // Try 'which' (Unix/Git Bash) first, then 'where' (Windows cmd)
152
+ for (const checker of ["which", "where"]) {
153
+ try {
154
+ execSync(`${checker} ${cmd}`, {
155
+ encoding: "utf8",
156
+ stdio: ["pipe", "pipe", "pipe"],
157
+ });
158
+ return true;
159
+ } catch {
160
+ // Try next checker
161
+ }
162
+ }
163
+ return false;
164
+ }
165
+
166
+ // Priority weight for sorting (lower = higher priority)
167
+ const PRIORITY_WEIGHT = { must: 0, should: 1, could: 2 };
168
+
169
+ // Maximum auto-pickups per session (infinite loop guard)
170
+ const MAX_AUTO_PICKUPS = 10;
171
+
172
+ /**
173
+ * Find the next eligible task from backlog.yaml.
174
+ * Filters: status === "backlog", all depends_on are "done".
175
+ * Sorts: priority (must > should > could), then due_date ascending.
176
+ * @returns {{ id: string, intent: string, priority: string }|null}
177
+ */
178
+ function findNextTask() {
179
+ try {
180
+ const backlog = readYaml(BACKLOG_FILE);
181
+ const tasks = backlog.tasks || [];
182
+
183
+ // Build status lookup for dependency checking
184
+ const statusMap = {};
185
+ for (const t of tasks) {
186
+ statusMap[t.id] = t.status;
187
+ }
188
+
189
+ // Filter candidates: backlog status + all deps done
190
+ const candidates = tasks.filter((t) => {
191
+ if (t.status !== "backlog") return false;
192
+ const deps = t.depends_on || [];
193
+ return deps.every((depId) => statusMap[depId] === "done");
194
+ });
195
+
196
+ if (candidates.length === 0) return null;
197
+
198
+ // Sort: priority ascending, then due_date ascending (null last)
199
+ candidates.sort((a, b) => {
200
+ const pa = PRIORITY_WEIGHT[a.priority] ?? 99;
201
+ const pb = PRIORITY_WEIGHT[b.priority] ?? 99;
202
+ if (pa !== pb) return pa - pb;
203
+
204
+ // due_date: null → Infinity for sorting
205
+ const da = a.due_date ? new Date(a.due_date).getTime() : Infinity;
206
+ const db = b.due_date ? new Date(b.due_date).getTime() : Infinity;
207
+ return da - db;
208
+ });
209
+
210
+ const next = candidates[0];
211
+ return { id: next.id, intent: next.intent, priority: next.priority };
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Bump version in package.json and return the new version string.
219
+ * @param {string} bumpType - "patch" | "minor" | "major"
220
+ * @returns {string|null} New version string, or null if package.json not found.
221
+ */
222
+ function bumpVersion(bumpType) {
223
+ const pkgPath = "package.json";
224
+ if (!fs.existsSync(pkgPath)) return null;
225
+
226
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
227
+ const parts = (pkg.version || "0.0.0").split(".").map(Number);
228
+
229
+ switch (bumpType) {
230
+ case "major":
231
+ parts[0] += 1;
232
+ parts[1] = 0;
233
+ parts[2] = 0;
234
+ break;
235
+ case "minor":
236
+ parts[1] += 1;
237
+ parts[2] = 0;
238
+ break;
239
+ case "patch":
240
+ default:
241
+ parts[2] += 1;
242
+ break;
243
+ }
244
+
245
+ const newVersion = parts.join(".");
246
+ pkg.version = newVersion;
247
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
248
+ return newVersion;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Main
253
+ // ---------------------------------------------------------------------------
254
+
255
+ try {
256
+ const input = readHookInput();
257
+
258
+ // Step 0: Load pipeline config
259
+ const config = loadPipelineConfig();
260
+ if (!config || config.auto_commit !== true) {
261
+ allow();
262
+ }
263
+
264
+ const autoCommit = config.auto_commit === true;
265
+ const autoPush = config.auto_push === true;
266
+ const autoPR = config.auto_pr === true;
267
+ const autoMerge = config.auto_merge === true;
268
+ const autoTag = config.auto_tag === true;
269
+ const versionBump = config.version_bump || "patch";
270
+ const commitFmt =
271
+ config.commit_message_format || "[{task_id}] STG{gate}: {intent}";
272
+
273
+ // Step 1: Get active task
274
+ const session = readSession();
275
+ const taskId = session.active_task_id;
276
+ if (!taskId) {
277
+ allow();
278
+ }
279
+
280
+ // Step 2: Get stage status
281
+ const taskData = getTaskData(taskId);
282
+ if (!taskData) {
283
+ allow();
284
+ }
285
+
286
+ const stageStatus = taskData.stage_status;
287
+ if (!stageStatus) {
288
+ allow();
289
+ }
290
+
291
+ // Step 3: STG gate progression
292
+ let summary = "";
293
+ const timestamp = new Date().toISOString();
294
+ const today = timestamp.slice(0, 10);
295
+
296
+ switch (stageStatus) {
297
+ case null:
298
+ case "stg0_passed":
299
+ case "stg1_passed": {
300
+ // STG2: Auto commit
301
+ if (!autoCommit) break;
302
+
303
+ const commitMsg = formatCommitMsg(
304
+ commitFmt,
305
+ taskId,
306
+ "2",
307
+ taskData.intent,
308
+ );
309
+ const branchName = `feature/${taskId}`;
310
+
311
+ try {
312
+ // Ensure feature branch
313
+ try {
314
+ executeTrusted(taskId, `git checkout -b "${branchName}"`);
315
+ } catch {
316
+ try {
317
+ executeTrusted(taskId, `git checkout "${branchName}"`);
318
+ } catch {
319
+ // Already on the branch
320
+ }
321
+ }
322
+
323
+ // Sync project views + README drift check (ADR-033, ADR-035)
324
+ if (commandExists("pwsh")) {
325
+ try {
326
+ executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
327
+ } catch {
328
+ // Non-critical
329
+ }
330
+ try {
331
+ executeTrusted(taskId, "pwsh scripts/sync-readme.ps1");
332
+ } catch {
333
+ // Non-critical — drift is reported but does not block
334
+ }
335
+ }
336
+
337
+ // Update backlog
338
+ updateBacklog(taskId, {
339
+ stage_status: "stg2_passed",
340
+ start_date: today,
341
+ branch: branchName,
342
+ stg_history_push: { gate: "stg2", passed_at: timestamp },
343
+ });
344
+
345
+ // Stage and commit
346
+ executeTrusted(taskId, "git add -A");
347
+ try {
348
+ executeTrusted(taskId, `git commit -m "${commitMsg}"`);
349
+ } catch {
350
+ // No changes to commit
351
+ }
352
+
353
+ summary = `STG2 passed: auto-committed [${taskId}]`;
354
+ } catch (err) {
355
+ summary = `STG2 error: ${err.message}`;
356
+ }
357
+ break;
358
+ }
359
+
360
+ case "stg2_passed": {
361
+ // STG3: Auto push
362
+ if (!autoPush) break;
363
+
364
+ const branchName = taskData.branch || `feature/${taskId}`;
365
+
366
+ try {
367
+ executeTrusted(taskId, `git push -u origin "${branchName}"`);
368
+
369
+ updateBacklog(taskId, {
370
+ stage_status: "stg3_passed",
371
+ stg_history_push: { gate: "stg3", passed_at: timestamp },
372
+ });
373
+
374
+ // Commit backlog update
375
+ executeTrusted(taskId, "git add tasks/backlog.yaml");
376
+ try {
377
+ executeTrusted(
378
+ taskId,
379
+ `git commit -m "[${taskId}] STG3: pushed to remote"`,
380
+ );
381
+ } catch {
382
+ // No changes
383
+ }
384
+
385
+ summary = `STG3 passed: pushed to ${branchName}`;
386
+ } catch (err) {
387
+ summary = `STG3 error: ${err.message}`;
388
+ }
389
+ break;
390
+ }
391
+
392
+ case "stg3_passed":
393
+ case "stg4_passed": {
394
+ // STG5: Auto PR
395
+ if (!autoPR) break;
396
+
397
+ if (!commandExists("gh")) {
398
+ summary = `gh CLI not found. Please create PR manually for feature/${taskId}`;
399
+ break;
400
+ }
401
+
402
+ try {
403
+ const prUrl = executeTrusted(
404
+ taskId,
405
+ `gh pr create --title "[${taskId}] ${taskData.intent}" --body "Auto-generated by shield-harness pipeline (ADR-031)"`,
406
+ ).trim();
407
+
408
+ if (prUrl) {
409
+ updateBacklog(taskId, {
410
+ stage_status: "stg5_passed",
411
+ pr_url: prUrl,
412
+ stg_history_push: { gate: "stg5", passed_at: timestamp },
413
+ });
414
+
415
+ executeTrusted(taskId, "git add tasks/backlog.yaml");
416
+ try {
417
+ executeTrusted(
418
+ taskId,
419
+ `git commit -m "[${taskId}] STG5: PR created"`,
420
+ );
421
+ } catch {
422
+ // No changes
423
+ }
424
+
425
+ summary = `STG5 passed: PR created at ${prUrl}`;
426
+ } else {
427
+ summary = `STG5: PR creation failed for feature/${taskId}`;
428
+ }
429
+ } catch (err) {
430
+ summary = `STG5 error: ${err.message}`;
431
+ }
432
+ break;
433
+ }
434
+
435
+ case "stg5_passed": {
436
+ // STG6: Auto merge
437
+ if (!autoMerge) break;
438
+
439
+ if (!commandExists("gh")) {
440
+ summary = "gh CLI not found. Please merge PR manually.";
441
+ break;
442
+ }
443
+
444
+ try {
445
+ const branchName = taskData.branch || `feature/${taskId}`;
446
+ const prNumberStr = executeTrusted(
447
+ taskId,
448
+ `gh pr list --head "${branchName}" --json number -q ".[0].number"`,
449
+ ).trim();
450
+
451
+ if (!prNumberStr) {
452
+ summary = `STG5: No PR found for ${branchName}`;
453
+ break;
454
+ }
455
+
456
+ // Check CI status
457
+ let failedCount;
458
+ try {
459
+ failedCount = executeTrusted(
460
+ taskId,
461
+ `gh pr checks ${prNumberStr} --json state -q '[.[] | select(.state != "SUCCESS")] | length'`,
462
+ ).trim();
463
+ } catch {
464
+ failedCount = "unknown";
465
+ }
466
+
467
+ if (failedCount !== "0") {
468
+ summary = `STG5: CI checks not passed yet (${failedCount} failing). Waiting...`;
469
+ break;
470
+ }
471
+
472
+ // Merge
473
+ executeTrusted(taskId, `gh pr merge ${prNumberStr} --squash`);
474
+ executeTrusted(taskId, "git checkout main");
475
+ executeTrusted(taskId, "git pull origin main");
476
+
477
+ // Branch cleanup
478
+ try {
479
+ executeTrusted(taskId, `git branch -d "${branchName}"`);
480
+ } catch {
481
+ // Already deleted
482
+ }
483
+ try {
484
+ executeTrusted(taskId, `git push origin --delete "${branchName}"`);
485
+ } catch {
486
+ // Already deleted remotely
487
+ }
488
+
489
+ // Update backlog to done
490
+ updateBacklog(taskId, {
491
+ status: "done",
492
+ stage_status: "stg6_passed",
493
+ completed_date: today,
494
+ stg_history_push: { gate: "stg6", passed_at: timestamp },
495
+ });
496
+
497
+ // Sync views
498
+ if (commandExists("pwsh")) {
499
+ try {
500
+ executeTrusted(taskId, "pwsh scripts/sync-project-views.ps1");
501
+ } catch {
502
+ // Non-critical
503
+ }
504
+ }
505
+
506
+ // Final commit
507
+ executeTrusted(taskId, "git add -A");
508
+ try {
509
+ executeTrusted(
510
+ taskId,
511
+ `git commit -m "[${taskId}] STG6: merged and completed"`,
512
+ );
513
+ } catch {
514
+ // No changes
515
+ }
516
+
517
+ // Auto-tag release version (TASK-013)
518
+ if (autoTag) {
519
+ try {
520
+ const newVersion = bumpVersion(versionBump);
521
+ if (newVersion) {
522
+ const tag = `v${newVersion}`;
523
+ executeTrusted(taskId, `git tag "${tag}"`);
524
+ executeTrusted(taskId, `git push origin "${tag}"`);
525
+ summary = `STG6 passed: PR #${prNumberStr} merged, tagged ${tag} [${taskId}]`;
526
+ } else {
527
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag skipped: no package.json)`;
528
+ }
529
+ } catch (tagErr) {
530
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}] (tag failed: ${tagErr.message})`;
531
+ }
532
+ } else {
533
+ summary = `STG6 passed: PR #${prNumberStr} merged [${taskId}]`;
534
+ }
535
+ } catch (err) {
536
+ summary = `STG6 error: ${err.message}`;
537
+ }
538
+ break;
539
+ }
540
+
541
+ case "stg6_passed": {
542
+ // Auto-pickup next task (ADR-034)
543
+ if (!config.auto_pickup_next_task) {
544
+ summary = `Task ${taskId} already completed (stg6_passed)`;
545
+ break;
546
+ }
547
+
548
+ // Infinite loop guard
549
+ const session = readSession();
550
+ const pickupCount = session.auto_pickup_count || 0;
551
+ if (pickupCount >= MAX_AUTO_PICKUPS) {
552
+ summary = `Auto-pickup limit reached (${MAX_AUTO_PICKUPS}). Stopping.`;
553
+ break;
554
+ }
555
+
556
+ const nextTask = findNextTask();
557
+ if (!nextTask) {
558
+ summary = "All tasks completed or no eligible tasks found.";
559
+ break;
560
+ }
561
+
562
+ try {
563
+ // Update next task to in_progress
564
+ const nextBranch = `feature/${nextTask.id}`;
565
+ updateBacklog(nextTask.id, {
566
+ status: "in_progress",
567
+ stage_status: "stg0_passed",
568
+ start_date: today,
569
+ branch: nextBranch,
570
+ stg_history_push: { gate: "stg0", passed_at: timestamp },
571
+ });
572
+
573
+ // Create branch
574
+ try {
575
+ executeTrusted(nextTask.id, `git checkout -b "${nextBranch}"`);
576
+ } catch {
577
+ try {
578
+ executeTrusted(nextTask.id, `git checkout "${nextBranch}"`);
579
+ } catch {
580
+ // Already on branch
581
+ }
582
+ }
583
+
584
+ // Update session
585
+ writeSession({
586
+ ...session,
587
+ active_task_id: nextTask.id,
588
+ auto_pickup_count: pickupCount + 1,
589
+ });
590
+
591
+ summary = `Auto-pickup: starting ${nextTask.id} (${nextTask.intent}) [${nextTask.priority}]`;
592
+ } catch (err) {
593
+ summary = `Auto-pickup error: ${err.message}`;
594
+ }
595
+ break;
596
+ }
597
+
598
+ default:
599
+ summary = `Unknown stage: ${stageStatus}`;
600
+ break;
601
+ }
602
+
603
+ // Step 4: Output
604
+ if (summary) {
605
+ try {
606
+ appendEvidence({
607
+ hook: HOOK_NAME,
608
+ event: "TaskCompleted",
609
+ decision: "allow",
610
+ task_id: taskId,
611
+ stage: stageStatus,
612
+ summary,
613
+ session_id: input.sessionId,
614
+ });
615
+ } catch {
616
+ // Non-blocking
617
+ }
618
+
619
+ allow(`[${HOOK_NAME}] ${summary}`);
620
+ }
621
+
622
+ allow();
623
+ } catch (_err) {
624
+ // Pipeline is operational — fail-open
625
+ allow();
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Exports (for testing)
630
+ // ---------------------------------------------------------------------------
631
+
632
+ module.exports = {
633
+ loadPipelineConfig,
634
+ getTaskData,
635
+ executeTrusted,
636
+ updateBacklog,
637
+ formatCommitMsg,
638
+ commandExists,
639
+ };