@hassangameryt/freeflow 0.8.0 → 0.9.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/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +96 -149
- package/capabilities/cognitive-routing/SKILL.md +21 -129
- package/capabilities/cognitive-routing/references/coordinator.md +163 -0
- package/capabilities/cognitive-routing/references/worker.md +38 -0
- package/capabilities/compaction/SKILL.md +3 -3
- package/capabilities/compaction/references/summary-format.md +26 -15
- package/capabilities/messaging/SKILL.md +49 -0
- package/capabilities/task-memory/SKILL.md +87 -0
- package/command-surface.json +6 -54
- package/gemini-extension.json +1 -1
- package/hooks/claude/hooks.json +13 -3
- package/hooks/codex/hooks.json +2 -1
- package/hooks/shared/runtime-context.mjs +69 -77
- package/package.json +25 -8
- package/pi-extension/dist/ask-user/ask.js +108 -0
- package/pi-extension/dist/ask-user/form.js +250 -0
- package/pi-extension/dist/ask-user/questions.js +66 -0
- package/pi-extension/dist/ask-user/tool.js +124 -0
- package/pi-extension/dist/cognitive-routing/assembler.js +35 -11
- package/pi-extension/dist/cognitive-routing/bootstrap.js +90 -0
- package/pi-extension/dist/cognitive-routing/config.js +3 -4
- package/pi-extension/dist/cognitive-routing/event-store.js +4 -1
- package/pi-extension/dist/cognitive-routing/gate.js +20 -5
- package/pi-extension/dist/cognitive-routing/handlers.js +15 -13
- package/pi-extension/dist/cognitive-routing/model-control.js +17 -8
- package/pi-extension/dist/cognitive-routing/projection.js +23 -6
- package/pi-extension/dist/cognitive-routing/provenance.js +3 -1
- package/pi-extension/dist/cognitive-routing/render.js +15 -15
- package/pi-extension/dist/cognitive-routing/runtime.js +4 -2
- package/pi-extension/dist/cognitive-routing/schemas.js +4 -4
- package/pi-extension/dist/cognitive-routing/session.js +3 -1
- package/pi-extension/dist/cognitive-routing/sources.js +29 -4
- package/pi-extension/dist/cognitive-routing/state.js +18 -8
- package/pi-extension/dist/cognitive-routing/status.js +4 -3
- package/pi-extension/dist/cognitive-routing/tools.js +29 -15
- package/pi-extension/dist/cognitive-routing/types.js +10 -1
- package/pi-extension/dist/compaction/carry.js +36 -22
- package/pi-extension/dist/compaction/config.js +4 -2
- package/pi-extension/dist/compaction/controller.js +52 -29
- package/pi-extension/dist/compaction/harness.js +19 -3
- package/pi-extension/dist/compaction/results.js +14 -13
- package/pi-extension/dist/compaction/tool.js +6 -6
- package/pi-extension/dist/host/catalog.js +20 -35
- package/pi-extension/dist/host/config-validation.js +233 -0
- package/pi-extension/dist/host/config.js +78 -91
- package/pi-extension/dist/host/inbound.js +142 -0
- package/pi-extension/dist/host/projection-tags.js +1 -1
- package/pi-extension/dist/host/prompts.js +24 -13
- package/pi-extension/dist/host/read-only-session.js +6 -1
- package/pi-extension/dist/host/request-history.js +7 -3
- package/pi-extension/dist/host/rows.js +25 -0
- package/pi-extension/dist/host/runtime-state.js +17 -2
- package/pi-extension/dist/host/section-keeper.js +85 -0
- package/pi-extension/dist/host/settings/freeflow-command.js +846 -93
- package/pi-extension/dist/host/settings/settings-view.js +27 -8
- package/pi-extension/dist/host/status.js +19 -5
- package/pi-extension/dist/index.js +638 -72
- package/pi-extension/dist/messaging/config.js +27 -0
- package/pi-extension/dist/messaging/messaging.js +398 -0
- package/pi-extension/dist/messaging/registry.js +57 -0
- package/pi-extension/dist/messaging/render.js +90 -0
- package/pi-extension/dist/messaging/tools.js +109 -0
- package/pi-extension/dist/messaging/transport.js +101 -0
- package/pi-extension/dist/provider-support/cache/keepalive.js +16 -4
- package/pi-extension/dist/task-memory/config.js +49 -0
- package/pi-extension/dist/task-memory/registry.js +119 -0
- package/pi-extension/dist/task-memory/render.js +107 -0
- package/pi-extension/dist/task-memory/runtime.js +994 -0
- package/pi-extension/dist/task-memory/tool.js +233 -0
- package/pi-extension/dist/tool-execution/apply-patch/tool.js +1 -1
- package/pi-extension/dist/tool-execution/background.js +220 -28
- package/pi-extension/dist/tool-execution/config.js +14 -3
- package/pi-extension/dist/tool-execution/file-tracking.js +24 -4
- package/pi-extension/dist/tool-execution/guidance-notices.js +124 -0
- package/pi-extension/dist/tool-execution/skill-reads.js +143 -0
- package/pi-extension/dist/tool-execution/tools.js +2 -2
- package/runtime/prompts/cognitive-routing.md +4 -4
- package/runtime/prompts/core.md +24 -14
- package/runtime/prompts/interaction-contract.md +7 -4
- package/runtime/prompts/tool-execution.md +3 -16
- package/runtime/prompts/working-method.md +16 -99
- package/skills/deliver-work/SKILL.md +76 -0
- package/skills/deliver-work/references/branch-closeout.md +134 -0
- package/skills/deliver-work/references/commit.md +83 -0
- package/skills/deliver-work/references/launch.md +94 -0
- package/skills/deliver-work/references/release.md +85 -0
- package/skills/deliver-work/scripts/commit-state.mjs +139 -0
- package/skills/deliver-work/scripts/delivery-state.mjs +107 -0
- package/skills/deliver-work/scripts/lib/git-state.mjs +178 -0
- package/skills/deliver-work/scripts/push-route.mjs +122 -0
- package/skills/deliver-work/scripts/release-preflight.mjs +177 -0
- package/skills/discuss-work/SKILL.md +148 -0
- package/skills/{discuss → discuss-work}/references/discussion-continuity.md +10 -27
- package/skills/discuss-work/references/learning-action.md +36 -0
- package/skills/discuss-work/scripts/choices.mjs +101 -0
- package/skills/discuss-work/scripts/lib/choices.mjs +159 -0
- package/skills/evaluate-behavior/SKILL.md +107 -0
- package/skills/evaluate-behavior/references/authoring.md +381 -0
- package/skills/evaluate-behavior/references/coding-tasks.md +145 -0
- package/skills/evaluate-behavior/references/conversations-pipelines-judges.md +177 -0
- package/skills/evaluate-behavior/references/design-and-measurement.md +273 -0
- package/skills/evaluate-behavior/references/running-and-evidence.md +245 -0
- package/skills/evaluate-behavior/scripts/claude-guard.mjs +76 -0
- package/skills/evaluate-behavior/scripts/guard-rules.mjs +309 -0
- package/skills/evaluate-behavior/scripts/lib/agreement.mjs +90 -0
- package/skills/evaluate-behavior/scripts/lib/arms.mjs +55 -0
- package/skills/evaluate-behavior/scripts/lib/budget.mjs +87 -0
- package/skills/evaluate-behavior/scripts/lib/build-source.mjs +287 -0
- package/skills/evaluate-behavior/scripts/lib/clear.mjs +74 -0
- package/skills/evaluate-behavior/scripts/lib/container.mjs +395 -0
- package/skills/evaluate-behavior/scripts/lib/decision.mjs +418 -0
- package/skills/evaluate-behavior/scripts/lib/definitions.mjs +1072 -0
- package/skills/evaluate-behavior/scripts/lib/freeflow.mjs +103 -0
- package/skills/evaluate-behavior/scripts/lib/grade-command.mjs +591 -0
- package/skills/evaluate-behavior/scripts/lib/grade-comparison.mjs +90 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-interaction.mjs +19 -18
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-support.mjs +32 -5
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-workspace.mjs +28 -12
- package/skills/evaluate-behavior/scripts/lib/grade.mjs +101 -0
- package/skills/evaluate-behavior/scripts/lib/hooks.mjs +98 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/confinement.mjs +196 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/constraints.mjs +111 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/index.mjs +48 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/isolation.mjs +74 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/launch.mjs +123 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/model-call.mjs +73 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/recorder.mjs +178 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/session.mjs +494 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/skills.mjs +81 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/index.mjs +31 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/names.mjs +3 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/confinement.mjs +173 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/host.mjs +148 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/index.mjs +70 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/isolation.mjs +81 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/model-call.mjs +79 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/process.mjs +241 -0
- package/skills/{evaluate-skill/scripts/lib → evaluate-behavior/scripts/lib/hosts/pi}/rpc.mjs +144 -26
- package/skills/evaluate-behavior/scripts/lib/identity.mjs +82 -0
- package/skills/evaluate-behavior/scripts/lib/judge.mjs +688 -0
- package/skills/evaluate-behavior/scripts/lib/metrics.mjs +332 -0
- package/skills/evaluate-behavior/scripts/lib/model-call.mjs +17 -0
- package/skills/evaluate-behavior/scripts/lib/participants.mjs +616 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/process.mjs +22 -0
- package/skills/evaluate-behavior/scripts/lib/queue.mjs +54 -0
- package/skills/evaluate-behavior/scripts/lib/reference.mjs +343 -0
- package/skills/evaluate-behavior/scripts/lib/reuse.mjs +277 -0
- package/skills/evaluate-behavior/scripts/lib/review.mjs +933 -0
- package/skills/evaluate-behavior/scripts/lib/runner.mjs +732 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/sandbox.mjs +119 -14
- package/skills/evaluate-behavior/scripts/lib/scan-shell.mjs +457 -0
- package/skills/evaluate-behavior/scripts/lib/scan.mjs +479 -0
- package/skills/evaluate-behavior/scripts/lib/seal.mjs +91 -0
- package/skills/evaluate-behavior/scripts/lib/shell.mjs +560 -0
- package/skills/evaluate-behavior/scripts/lib/source-override.mjs +64 -0
- package/skills/evaluate-behavior/scripts/lib/subject-file.mjs +88 -0
- package/skills/{evaluate-skill/scripts/lib/pi.mjs → evaluate-behavior/scripts/lib/subject.mjs} +235 -265
- package/skills/evaluate-behavior/scripts/lib/task-copy.mjs +124 -0
- package/skills/evaluate-behavior/scripts/lib/trials.mjs +524 -0
- package/skills/evaluate-behavior/scripts/lib/validity.mjs +68 -0
- package/skills/evaluate-behavior/scripts/lib/view-html.mjs +454 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/view.mjs +159 -18
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-command-tool.mjs +21 -6
- package/skills/evaluate-behavior/scripts/pi-container-route.mjs +23 -0
- package/skills/evaluate-behavior/scripts/pi-guard.mjs +77 -0
- package/skills/evaluate-behavior/scripts/pi-login-refresh.mjs +31 -0
- package/skills/evaluate-behavior/scripts/skill-eval.mjs +586 -0
- package/skills/execute-work/SKILL.md +96 -104
- package/skills/execute-work/references/code-practices.md +27 -51
- package/skills/execute-work/references/design.md +271 -0
- package/skills/execute-work/references/diagnose.md +117 -0
- package/skills/execute-work/references/migration.md +108 -0
- package/skills/execute-work/references/simplify.md +65 -0
- package/skills/execute-work/scripts/self-review.mjs +422 -0
- package/skills/execute-work/scripts/touch-map.mjs +290 -0
- package/skills/review-work/SKILL.md +89 -65
- package/skills/review-work/references/adjudication.md +94 -0
- package/skills/review-work/references/artifact.md +198 -0
- package/skills/review-work/references/evals-and-judges.md +66 -0
- package/skills/review-work/references/work.md +177 -0
- package/skills/review-work/scripts/check-brief.mjs +128 -0
- package/skills/review-work/scripts/review-packet.mjs +233 -0
- package/skills/setup-freeflow/SKILL.md +68 -17
- package/skills/track-work/SKILL.md +52 -20
- package/skills/track-work/references/handoff.md +190 -0
- package/skills/track-work/references/projects.md +51 -0
- package/skills/track-work/references/working-record-format.md +36 -18
- package/skills/track-work/scripts/lib/document.mjs +17 -13
- package/skills/track-work/scripts/lib/format.mjs +2 -0
- package/skills/track-work/scripts/lib/recover.mjs +254 -0
- package/skills/track-work/scripts/lib/registry.mjs +656 -0
- package/skills/track-work/scripts/lib/search.mjs +40 -0
- package/skills/track-work/scripts/lib/store.mjs +46 -15
- package/skills/track-work/scripts/lib/views.mjs +50 -1
- package/skills/track-work/scripts/working-record.mjs +232 -13
- package/skills/verify-work/SKILL.md +45 -37
- package/skills/verify-work/references/feature-map.md +120 -0
- package/skills/verify-work/references/runtime-evidence.md +203 -0
- package/skills/verify-work/references/test-design.md +25 -6
- package/skills/verify-work/scripts/test-changes.mjs +246 -0
- package/skills/write-docs/SKILL.md +113 -25
- package/skills/write-docs/references/documentation-shapes.md +68 -3
- package/skills/write-docs/references/spec-and-plan-shapes.md +308 -0
- package/skills/write-docs/scripts/artifact-check.mjs +148 -0
- package/skills/write-docs/scripts/docs-check.mjs +96 -6
- package/skills/write-docs/scripts/plan-lint.mjs +123 -0
- package/skills/write-skill/SKILL.md +92 -52
- package/skills/write-skill/references/activation-and-wording.md +143 -0
- package/skills/write-skill/references/placement-and-dependencies.md +154 -0
- package/skills/write-skill/scripts/lib/compare.mjs +701 -0
- package/skills/write-skill/scripts/lib/skill-author.mjs +197 -3
- package/skills/write-skill/scripts/skill-author.mjs +51 -8
- package/capabilities/cognitive-routing/references/both-mode.md +0 -36
- package/capabilities/cognitive-routing/references/executor-mode.md +0 -48
- package/capabilities/cognitive-routing/references/helper-mode.md +0 -47
- package/plugin.json +0 -33
- package/skills/action-selection/SKILL.md +0 -92
- package/skills/action-selection/references/trajectory-stalls.md +0 -73
- package/skills/bypass/SKILL.md +0 -153
- package/skills/commit-work/SKILL.md +0 -93
- package/skills/commit-work/references/git-state-edges.md +0 -94
- package/skills/commit-work/references/simple-push.md +0 -94
- package/skills/commit-work/references/staging-decisions.md +0 -106
- package/skills/decision-gate/SKILL.md +0 -87
- package/skills/design-for-depth/SKILL.md +0 -108
- package/skills/design-for-depth/references/design-pressure-signals.md +0 -84
- package/skills/design-for-depth/references/implementation-decisions.md +0 -76
- package/skills/design-for-depth/references/interface-design-loop.md +0 -105
- package/skills/design-for-depth/references/module-and-dependency-design.md +0 -117
- package/skills/design-for-depth/references/state-and-failure-boundaries.md +0 -125
- package/skills/diagnose-failure/SKILL.md +0 -140
- package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +0 -50
- package/skills/diagnose-failure/references/flaky-and-performance-diagnosis.md +0 -51
- package/skills/discuss/SKILL.md +0 -140
- package/skills/evaluate-skill/SKILL.md +0 -188
- package/skills/evaluate-skill/references/evaluation-definition-schema.md +0 -415
- package/skills/evaluate-skill/references/evaluation-design.md +0 -112
- package/skills/evaluate-skill/references/execution-and-evidence.md +0 -111
- package/skills/evaluate-skill/references/review-and-revision.md +0 -94
- package/skills/evaluate-skill/scripts/lib/definitions.mjs +0 -448
- package/skills/evaluate-skill/scripts/lib/grade-comparison.mjs +0 -60
- package/skills/evaluate-skill/scripts/lib/grade.mjs +0 -64
- package/skills/evaluate-skill/scripts/lib/review.mjs +0 -196
- package/skills/evaluate-skill/scripts/lib/runner.mjs +0 -405
- package/skills/evaluate-skill/scripts/lib/trials.mjs +0 -103
- package/skills/evaluate-skill/scripts/pi-guard.mjs +0 -181
- package/skills/evaluate-skill/scripts/skill-eval.mjs +0 -182
- package/skills/execute-work/references/execute-work-edges.md +0 -78
- package/skills/finish-branch/SKILL.md +0 -117
- package/skills/finish-branch/references/integration-options.md +0 -80
- package/skills/handoff/SKILL.md +0 -82
- package/skills/handoff/references/handoff-templates.md +0 -150
- package/skills/launch-work/SKILL.md +0 -116
- package/skills/launch-work/references/launch-readiness.md +0 -85
- package/skills/migration-work/SKILL.md +0 -115
- package/skills/migration-work/references/migration-lifecycle.md +0 -82
- package/skills/release-work/SKILL.md +0 -118
- package/skills/release-work/references/release-evidence.md +0 -78
- package/skills/review-artifact/SKILL.md +0 -143
- package/skills/review-artifact/references/adjudicate-artifact-review.md +0 -101
- package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +0 -159
- package/skills/review-work/references/adjudicate-work-review.md +0 -97
- package/skills/review-work/references/independent-work-reviewer-contract.md +0 -139
- package/skills/review-work/references/security-risk-lens.md +0 -67
- package/skills/setup-freeflow/references/activation-contract.md +0 -103
- package/skills/setup-freeflow/references/host-setup.md +0 -67
- package/skills/simplify-code/SKILL.md +0 -85
- package/skills/simplify-code/references/simplification-patterns.md +0 -70
- package/skills/verify-work/references/browser-runtime-evidence.md +0 -51
- package/skills/verify-work/references/integration-evidence.md +0 -49
- package/skills/verify-work/references/performance-evidence.md +0 -59
- package/skills/workflow/SKILL.md +0 -171
- package/skills/workflow/references/domain-skill-composition.md +0 -45
- package/skills/write-plan/SKILL.md +0 -92
- package/skills/write-plan/references/plan-shapes.md +0 -156
- package/skills/write-skill/references/activation-boundaries.md +0 -106
- package/skills/write-skill/references/agent-first-instructions.md +0 -133
- package/skills/write-skill/references/progressive-disclosure.md +0 -129
- package/skills/write-skill/references/skill-dependency-graphs.md +0 -192
- package/skills/write-spec/SKILL.md +0 -85
- package/skills/write-spec/references/artifact-standards.md +0 -86
- package/skills/write-spec/references/decision-records.md +0 -80
- package/skills/write-spec/references/spec-shapes.md +0 -143
- /package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/evidence.mjs +0 -0
- /package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-observer.mjs +0 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
appendFile,
|
|
5
|
+
copyFile,
|
|
6
|
+
cp,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
readdir,
|
|
10
|
+
readFile,
|
|
11
|
+
readlink,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
stat,
|
|
15
|
+
symlink,
|
|
16
|
+
unlink,
|
|
17
|
+
writeFile,
|
|
18
|
+
} from "node:fs/promises";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
import { slugify } from "./format.mjs";
|
|
24
|
+
import { LAST_TASK_NUMBER, nextTaskNumber, STORE_MARKER, taskRoot } from "./store.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The task registry: projects, registered tasks, their parents, and which session owns each record. One JSON file
|
|
28
|
+
* under the Freeflow home (FREEFLOW_HOME, default ~/.freeflow), shared by every session on every host. Each project
|
|
29
|
+
* keeps its records in a store under the home, created by this script's own init, so a registered record is an
|
|
30
|
+
* ordinary Working Record. The registry is canonical for membership, parentage and ownership; records are canonical
|
|
31
|
+
* for task content.
|
|
32
|
+
*/
|
|
33
|
+
const execFile = promisify(execFileCallback);
|
|
34
|
+
const SCRIPT = fileURLToPath(new URL("../working-record.mjs", import.meta.url));
|
|
35
|
+
|
|
36
|
+
export const TASK_ID = /^[a-z0-9-]+\/task-\d+$/;
|
|
37
|
+
|
|
38
|
+
export class RegistryError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "RegistryError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function freeflowHome() {
|
|
46
|
+
return process.env.FREEFLOW_HOME || join(homedir(), ".freeflow");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function projectSlug(name) {
|
|
50
|
+
return slugify(name).slice(0, 48) || "project";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A directory as the user wrote it, with a leading ~ meaning the home directory (commands typed in an agent are not
|
|
54
|
+
* expanded by a shell). */
|
|
55
|
+
export function directory(path) {
|
|
56
|
+
return resolve(path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The command a user pastes into a new terminal to start an agent in a child's worktree: cd there, keep a
|
|
63
|
+
* non-default Freeflow home, and run the agent's command (pi, claude, codex, ...).
|
|
64
|
+
*/
|
|
65
|
+
export function startCommand(path, agent) {
|
|
66
|
+
const home = process.env.FREEFLOW_HOME;
|
|
67
|
+
const keepHome =
|
|
68
|
+
home && resolve(home) !== join(homedir(), ".freeflow") ? `FREEFLOW_HOME=${quote(resolve(home))} ` : "";
|
|
69
|
+
return `cd ${quote(path)}${agent ? ` && ${keepHome}${agent}` : ""}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const empty = () => ({ version: 1, projects: {}, tasks: {} });
|
|
73
|
+
|
|
74
|
+
/** A removal's result for the agent and the user: what went, where it is, what was left and how to remove it. */
|
|
75
|
+
export function removalText(result, trash) {
|
|
76
|
+
const ids = result.tasks.map((task) => task.id);
|
|
77
|
+
const what = result.project
|
|
78
|
+
? `project ${result.project.name} (${result.project.slug})${ids.length ? ` and its tasks ${ids.join(", ")}` : ""}`
|
|
79
|
+
: ids.length > 1
|
|
80
|
+
? `${ids[0]} and its children ${ids.slice(1).join(", ")}`
|
|
81
|
+
: ids[0];
|
|
82
|
+
const lines = [
|
|
83
|
+
`Removed ${what}. ${result.trash.length ? `The records are in the trash: ${result.trash.join(", ")}.` : "There were no record files to move."} Only the user empties the trash (${trash}).`,
|
|
84
|
+
];
|
|
85
|
+
if (result.unlinked?.length) lines.push(`Project links removed: ${result.unlinked.join(", ")}.`);
|
|
86
|
+
if (result.worktrees.length)
|
|
87
|
+
lines.push(
|
|
88
|
+
"Git worktrees and branches were left in place. To remove them, run from the main repository:",
|
|
89
|
+
...result.worktrees.map(
|
|
90
|
+
(worktree) =>
|
|
91
|
+
`- ${worktree.id}: git worktree remove ${quote(worktree.path)}${worktree.branch ? ` && git branch -D ${quote(worktree.branch)}` : ""}`,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A path with its symlinks resolved, or as given when it does not exist. */
|
|
98
|
+
function real(path) {
|
|
99
|
+
try {
|
|
100
|
+
return realpathSync(path);
|
|
101
|
+
} catch {
|
|
102
|
+
return resolve(path);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The project link (Task 004 D-003): a symlink in each repository linked to a project, and in its children's worktrees,
|
|
108
|
+
* to the project's store, so the user can browse and edit its records in the editor. Agents address records by task ID
|
|
109
|
+
* or real path.
|
|
110
|
+
*/
|
|
111
|
+
export const PROJECT_LINK = ".freeflow/project";
|
|
112
|
+
|
|
113
|
+
export class Registry {
|
|
114
|
+
constructor(home = freeflowHome()) {
|
|
115
|
+
this.home = home;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get path() {
|
|
119
|
+
return join(this.home, "registry.json");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async load() {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = await readFile(this.path, "utf8");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error?.code === "ENOENT") return { text: undefined, data: empty() };
|
|
128
|
+
throw new RegistryError(`The task registry cannot be read: ${error?.message ?? error}`);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const data = JSON.parse(text);
|
|
132
|
+
if (data?.version !== 1 || typeof data.projects !== "object" || typeof data.tasks !== "object") throw new Error();
|
|
133
|
+
return { text, data };
|
|
134
|
+
} catch {
|
|
135
|
+
throw new RegistryError(`The task registry ${this.path} is not valid; it was left unchanged.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async read() {
|
|
140
|
+
return (await this.load()).data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Apply a change: read, change a copy, write a temporary file, and rename it over the registry only if the file did
|
|
145
|
+
* not change meanwhile. A change by another session is re-read and the operation applied once more; a second
|
|
146
|
+
* conflict is reported rather than retried. The check and the rename are two steps, so a write landing between them
|
|
147
|
+
* is lost; the window is small and accepted.
|
|
148
|
+
*/
|
|
149
|
+
async update(change) {
|
|
150
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
151
|
+
const { text, data } = await this.load();
|
|
152
|
+
const result = await change(data);
|
|
153
|
+
await mkdir(this.home, { recursive: true });
|
|
154
|
+
const temporary = `${this.path}.${process.pid}.${Date.now()}.tmp`;
|
|
155
|
+
await writeFile(temporary, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
156
|
+
const now = await readFile(this.path, "utf8").catch(() => undefined);
|
|
157
|
+
if (now !== text) {
|
|
158
|
+
await unlink(temporary).catch(() => {});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
await rename(temporary, this.path);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
throw new RegistryError("The task registry kept changing under another session; nothing was written. Try again.");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The repository root for a directory: its Git top level, or the directory itself outside Git. */
|
|
168
|
+
async repoRoot(cwd) {
|
|
169
|
+
try {
|
|
170
|
+
return resolve((await execFile("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim());
|
|
171
|
+
} catch {
|
|
172
|
+
return resolve(cwd);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A project by name or slug. */
|
|
177
|
+
async project(name) {
|
|
178
|
+
const slug = projectSlug(name);
|
|
179
|
+
const project = (await this.read()).projects[slug];
|
|
180
|
+
return project ? { slug, project } : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async projectFor(cwd) {
|
|
184
|
+
const root = await this.repoRoot(cwd);
|
|
185
|
+
const data = await this.read();
|
|
186
|
+
for (const [slug, project] of Object.entries(data.projects))
|
|
187
|
+
if (project.repos.some((repo) => root === repo || root.startsWith(repo + sep))) return { slug, project };
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Create a project and link the repository containing cwd; worktrees is where its children's worktrees go. */
|
|
192
|
+
async createProject(name, cwd, worktrees) {
|
|
193
|
+
const slug = projectSlug(name);
|
|
194
|
+
const root = await this.repoRoot(cwd);
|
|
195
|
+
const store = join(this.home, "projects", slug);
|
|
196
|
+
await mkdir(store, { recursive: true });
|
|
197
|
+
// Marks the directory as a project store, so Track Work keeps its records in tasks/ rather than .freeflow/tasks/.
|
|
198
|
+
await writeFile(join(store, STORE_MARKER), `${JSON.stringify({ version: 1, project: slug })}\n`, {
|
|
199
|
+
flag: "wx",
|
|
200
|
+
}).catch((error) => {
|
|
201
|
+
if (error?.code !== "EEXIST") throw error;
|
|
202
|
+
});
|
|
203
|
+
await this.update((data) => {
|
|
204
|
+
if (data.projects[slug]) throw new RegistryError(`A project named ${slug} already exists.`);
|
|
205
|
+
for (const project of Object.values(data.projects))
|
|
206
|
+
if (project.repos.includes(root)) throw new RegistryError(`${root} is already linked to ${project.name}.`);
|
|
207
|
+
data.projects[slug] = { name, store, repos: [root], ...(worktrees ? { worktrees: directory(worktrees) } : {}) };
|
|
208
|
+
});
|
|
209
|
+
// A project removed under this name keeps its task numbers retired, so its old IDs never name new tasks.
|
|
210
|
+
const retired = (await this.read()).retired?.[slug];
|
|
211
|
+
if (retired) {
|
|
212
|
+
const tasks = taskRoot(store);
|
|
213
|
+
await mkdir(tasks, { recursive: true });
|
|
214
|
+
const marker = join(tasks, LAST_TASK_NUMBER);
|
|
215
|
+
const recorded = Number((await readFile(marker, "utf8").catch(() => "0")).trim()) || 0;
|
|
216
|
+
if (retired > recorded) await writeFile(marker, `${retired}\n`);
|
|
217
|
+
}
|
|
218
|
+
await this.linkStore(root, store);
|
|
219
|
+
return slug;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async linkProject(name, cwd, worktrees) {
|
|
223
|
+
const slug = projectSlug(name);
|
|
224
|
+
const root = await this.repoRoot(cwd);
|
|
225
|
+
const store = await this.update((data) => {
|
|
226
|
+
const project = data.projects[slug];
|
|
227
|
+
if (!project) throw new RegistryError(`No project named ${name}.`);
|
|
228
|
+
for (const [other, entry] of Object.entries(data.projects))
|
|
229
|
+
if (other !== slug && entry.repos.includes(root))
|
|
230
|
+
throw new RegistryError(`${root} is already linked to ${entry.name}.`);
|
|
231
|
+
data.projects[slug] = {
|
|
232
|
+
...project,
|
|
233
|
+
repos: project.repos.includes(root) ? project.repos : [...project.repos, root],
|
|
234
|
+
...(worktrees ? { worktrees: directory(worktrees) } : {}),
|
|
235
|
+
};
|
|
236
|
+
return project.store;
|
|
237
|
+
});
|
|
238
|
+
await this.linkStore(root, store);
|
|
239
|
+
return slug;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Make the project link in a directory and keep it out of Git through the clone's info/exclude, not a shared
|
|
244
|
+
* .gitignore. A link to the store is kept, a link elsewhere replaced, and anything else at that path left alone.
|
|
245
|
+
* Where no link can be made (Windows without the right to make one), the result says why; the store's real path
|
|
246
|
+
* still works.
|
|
247
|
+
*/
|
|
248
|
+
async linkStore(dir, store) {
|
|
249
|
+
const link = join(dir, PROJECT_LINK);
|
|
250
|
+
let existing;
|
|
251
|
+
try {
|
|
252
|
+
existing = await lstat(link);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error?.code !== "ENOENT") return { link, error: String(error?.message ?? error) };
|
|
255
|
+
}
|
|
256
|
+
if (existing && !existing.isSymbolicLink()) return { link, error: `${link} is not a link; it was left alone.` };
|
|
257
|
+
try {
|
|
258
|
+
if (existing && real(await readlink(link)) === real(store)) return { link };
|
|
259
|
+
if (existing) await unlink(link);
|
|
260
|
+
await mkdir(dirname(link), { recursive: true });
|
|
261
|
+
// A junction on Windows needs no extra right; other systems ignore the type.
|
|
262
|
+
await symlink(store, link, "junction");
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return { link, error: `The project link was not made: ${error?.message ?? error}` };
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await execFile("git", ["-C", dir, "check-ignore", "-q", "--", PROJECT_LINK]);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Exit 1: not ignored. Any other failure (not a Git repository, no Git) leaves nothing to ignore it from.
|
|
270
|
+
if (error?.code !== 1) return { link };
|
|
271
|
+
try {
|
|
272
|
+
const where = (await execFile("git", ["-C", dir, "rev-parse", "--git-path", "info/exclude"])).stdout.trim();
|
|
273
|
+
const exclude = resolve(dir, where);
|
|
274
|
+
await mkdir(dirname(exclude), { recursive: true });
|
|
275
|
+
const text = await readFile(exclude, "utf8").catch(() => "");
|
|
276
|
+
await appendFile(exclude, `${text && !text.endsWith("\n") ? "\n" : ""}/${PROJECT_LINK}\n`);
|
|
277
|
+
} catch (failure) {
|
|
278
|
+
return { link, error: `The project link is not ignored by Git: ${failure?.message ?? failure}` };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { link };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The project link for the repository containing cwd, made if missing, when it belongs to a project: linked to it, or
|
|
286
|
+
* a registered task's directory (a child's worktree). Undefined when it belongs to none.
|
|
287
|
+
*/
|
|
288
|
+
async linkHere(cwd) {
|
|
289
|
+
const data = await this.read();
|
|
290
|
+
const project = (await this.projectFor(cwd))?.project ?? data.projects[(await this.here(cwd))[0]?.task.project];
|
|
291
|
+
return project ? this.linkStore(await this.repoRoot(cwd), project.store) : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Create a record in the project's store with this script's init, then register it; primary is its directory. */
|
|
295
|
+
async createTask(slug, title, goal, parent, primary) {
|
|
296
|
+
const data = await this.read();
|
|
297
|
+
const project = data.projects[slug];
|
|
298
|
+
if (!project) throw new RegistryError(`No project ${slug}.`);
|
|
299
|
+
const lines = ["### Goal", `- ${goal}`];
|
|
300
|
+
if (parent) {
|
|
301
|
+
const parentTask = data.tasks[parent];
|
|
302
|
+
if (!parentTask) throw new RegistryError(`No task ${parent}.`);
|
|
303
|
+
if (parentTask.parent) throw new RegistryError(`${parent} is a child; a child has no children.`);
|
|
304
|
+
lines.push("", "### What defines this task", `- Child of ${parent} (${parentTask.record}).`);
|
|
305
|
+
}
|
|
306
|
+
lines.push("", "### Next useful action", "- Agree the first step with the user.", "");
|
|
307
|
+
const child = execFileCallback(process.execPath, [
|
|
308
|
+
SCRIPT,
|
|
309
|
+
"init",
|
|
310
|
+
"--root",
|
|
311
|
+
project.store,
|
|
312
|
+
"--name",
|
|
313
|
+
title,
|
|
314
|
+
"--input",
|
|
315
|
+
"-",
|
|
316
|
+
]);
|
|
317
|
+
const output = new Promise((done, fail) => {
|
|
318
|
+
let stdout = "";
|
|
319
|
+
let stderr = "";
|
|
320
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk));
|
|
321
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk));
|
|
322
|
+
child.on("close", (code) => (code === 0 ? done(stdout.trim()) : fail(new RegistryError(stderr.trim()))));
|
|
323
|
+
});
|
|
324
|
+
child.stdin?.end(lines.join("\n"));
|
|
325
|
+
const record = resolve(await output);
|
|
326
|
+
const id = `${slug}/${basename(dirname(record)).match(/^task-\d+/)?.[0]}`;
|
|
327
|
+
await this.update((next) => {
|
|
328
|
+
if (next.tasks[id]) throw new RegistryError(`${id} is already registered.`);
|
|
329
|
+
next.tasks[id] = {
|
|
330
|
+
record,
|
|
331
|
+
project: slug,
|
|
332
|
+
title,
|
|
333
|
+
...(parent ? { parent } : {}),
|
|
334
|
+
...(primary ? { environment: { primary: resolve(primary), directories: [] } } : {}),
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
return { id, record };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Move an unregistered record's task directory into a project store, with the next free number, and register it. */
|
|
341
|
+
async importRecord(projectName, record) {
|
|
342
|
+
const slug = projectSlug(projectName);
|
|
343
|
+
const data = await this.read();
|
|
344
|
+
const project = data.projects[slug];
|
|
345
|
+
if (!project) throw new RegistryError(`No project ${projectName}.`);
|
|
346
|
+
if (Object.values(data.tasks).some((task) => task.record === record))
|
|
347
|
+
throw new RegistryError(`${record} is already registered.`);
|
|
348
|
+
const source = dirname(record);
|
|
349
|
+
// Only a Track Work task directory moves: anything else could take unrelated files with it.
|
|
350
|
+
if (!/^task-\d{3,}-[a-z0-9][a-z0-9-]*$/.test(basename(source)))
|
|
351
|
+
throw new RegistryError(
|
|
352
|
+
`${record} is not in a Track Work task directory (task-NNN-name), so it cannot be imported.`,
|
|
353
|
+
);
|
|
354
|
+
const tasks = taskRoot(project.store);
|
|
355
|
+
await mkdir(tasks, { recursive: true });
|
|
356
|
+
const number = String(await nextTaskNumber(tasks)).padStart(3, "0");
|
|
357
|
+
const target = join(tasks, basename(source).replace(/^task-\d+/, `task-${number}`));
|
|
358
|
+
try {
|
|
359
|
+
await rename(source, target);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error?.code !== "EXDEV") throw error;
|
|
362
|
+
await cp(source, target, { recursive: true, errorOnExist: true });
|
|
363
|
+
await rm(source, { recursive: true });
|
|
364
|
+
}
|
|
365
|
+
const moved = join(target, "record.md");
|
|
366
|
+
const title = /^# Working Record:\s*(.+?)\s*$/m.exec(await readFile(moved, "utf8"))?.[1] ?? basename(target);
|
|
367
|
+
const id = `${slug}/task-${number}`;
|
|
368
|
+
await this.update((next) => {
|
|
369
|
+
if (next.tasks[id]) throw new RegistryError(`${id} is already registered.`);
|
|
370
|
+
next.tasks[id] = { record: moved, project: slug, title };
|
|
371
|
+
});
|
|
372
|
+
return { id, record: moved };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async task(id) {
|
|
376
|
+
return (await this.read()).tasks[id];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async byRecord(record) {
|
|
380
|
+
// The same file by another path (through the project link, or a symlinked home) is the same record.
|
|
381
|
+
const tasks = Object.entries((await this.read()).tasks);
|
|
382
|
+
const target = real(record);
|
|
383
|
+
const entry =
|
|
384
|
+
tasks.find(([, task]) => task.record === record) ?? tasks.find(([, task]) => real(task.record) === target);
|
|
385
|
+
return entry ? { id: entry[0], task: entry[1] } : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async children(id) {
|
|
389
|
+
return Object.entries((await this.read()).tasks)
|
|
390
|
+
.filter(([, task]) => task.parent === id)
|
|
391
|
+
.map(([childId, task]) => ({ id: childId, task }));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async projectTasks(slug) {
|
|
395
|
+
return Object.entries((await this.read()).tasks)
|
|
396
|
+
.filter(([, task]) => task.project === slug)
|
|
397
|
+
.map(([id, task]) => ({ id, task }));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Make a session the record's owner. Returns the other owner instead when one holds it and takeover is false; the
|
|
402
|
+
* check and the change happen in one registry update, so two sessions cannot both become owner.
|
|
403
|
+
*/
|
|
404
|
+
async own(id, owner, takeover) {
|
|
405
|
+
return this.update((data) => {
|
|
406
|
+
const task = data.tasks[id];
|
|
407
|
+
if (!task) throw new RegistryError(`No task ${id}.`);
|
|
408
|
+
if (task.owner && task.owner.session !== owner.session && !takeover) return { owned: false, by: task.owner };
|
|
409
|
+
data.tasks[id] = { ...task, owner };
|
|
410
|
+
return { owned: true };
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async release(id, session) {
|
|
415
|
+
const task = await this.task(id);
|
|
416
|
+
if (task?.owner?.session !== session) return;
|
|
417
|
+
await this.update((data) => {
|
|
418
|
+
const current = data.tasks[id];
|
|
419
|
+
if (current?.owner?.session !== session) return;
|
|
420
|
+
const { owner: _owner, ...rest } = current;
|
|
421
|
+
data.tasks[id] = rest;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Change where a task's work happens: its primary directory, and additional directories. */
|
|
426
|
+
async setEnvironment(id, { primary, add, remove } = {}) {
|
|
427
|
+
return this.update((data) => {
|
|
428
|
+
const task = data.tasks[id];
|
|
429
|
+
if (!task) throw new RegistryError(`No task ${id}.`);
|
|
430
|
+
const environment = {
|
|
431
|
+
primary: task.environment?.primary,
|
|
432
|
+
directories: [...(task.environment?.directories ?? [])],
|
|
433
|
+
};
|
|
434
|
+
if (primary) environment.primary = directory(primary);
|
|
435
|
+
if (add && !environment.directories.includes(directory(add))) environment.directories.push(directory(add));
|
|
436
|
+
if (remove) environment.directories = environment.directories.filter((dir) => dir !== directory(remove));
|
|
437
|
+
data.tasks[id] = { ...task, environment };
|
|
438
|
+
return environment;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** A task's environment with what Git says about its primary directory now; branches are read, never stored. */
|
|
443
|
+
async environment(id) {
|
|
444
|
+
const task = await this.task(id);
|
|
445
|
+
if (!task) throw new RegistryError(`No task ${id}.`);
|
|
446
|
+
const primary = task.environment?.primary;
|
|
447
|
+
const directories = task.environment?.directories ?? [];
|
|
448
|
+
if (!primary) return { primary: undefined, directories };
|
|
449
|
+
const exists = await stat(primary)
|
|
450
|
+
.then((info) => info.isDirectory())
|
|
451
|
+
.catch(() => false);
|
|
452
|
+
if (!exists) return { primary, directories, exists: false };
|
|
453
|
+
const git = async (...args) =>
|
|
454
|
+
(await execFile("git", ["-C", primary, ...args]).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
455
|
+
const branch = (await git("branch", "--show-current")) || undefined;
|
|
456
|
+
const worktree = (await git("rev-parse", "--git-dir")) !== (await git("rev-parse", "--git-common-dir"));
|
|
457
|
+
return { primary, directories, exists: true, branch, worktree };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Where removed tasks and projects go; only the user empties it (Task 006). */
|
|
461
|
+
get trash() {
|
|
462
|
+
return join(this.home, "trash");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* What removing a task, or a project, would take: the tasks (a task with its children, or all of a project's), their
|
|
467
|
+
* owners, and the Git worktrees fan-out made for them, which removal leaves in place.
|
|
468
|
+
*/
|
|
469
|
+
async removal({ task: id, project: name } = {}) {
|
|
470
|
+
const data = await this.read();
|
|
471
|
+
let project;
|
|
472
|
+
let entries;
|
|
473
|
+
if (name !== undefined) {
|
|
474
|
+
const found = await this.project(name);
|
|
475
|
+
if (!found) throw new RegistryError(`No project named ${name}.`);
|
|
476
|
+
project = found;
|
|
477
|
+
entries = Object.entries(data.tasks).filter(([, task]) => task.project === found.slug);
|
|
478
|
+
} else {
|
|
479
|
+
const task = data.tasks[id];
|
|
480
|
+
if (!task) throw new RegistryError(`No task ${id}.`);
|
|
481
|
+
entries = [[id, task], ...Object.entries(data.tasks).filter(([, other]) => other.parent === id)];
|
|
482
|
+
}
|
|
483
|
+
const worktrees = [];
|
|
484
|
+
for (const [taskId] of entries) {
|
|
485
|
+
const environment = await this.environment(taskId).catch(() => undefined);
|
|
486
|
+
if (environment?.worktree && environment.primary)
|
|
487
|
+
worktrees.push({ id: taskId, path: environment.primary, branch: environment.branch });
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
...(project ? { project: { slug: project.slug, name: project.project.name } } : {}),
|
|
491
|
+
tasks: entries.map(([taskId, task]) => ({ id: taskId, title: task.title, owner: task.owner?.session })),
|
|
492
|
+
worktrees,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Move a directory into the trash under a dated name; the trash is in the Freeflow home, so this is a rename. */
|
|
497
|
+
async toTrash(path, name) {
|
|
498
|
+
await mkdir(this.trash, { recursive: true });
|
|
499
|
+
const target = join(this.trash, `${new Date().toISOString().replace(/[:.]/g, "-")}-${name}`);
|
|
500
|
+
await rename(path, target);
|
|
501
|
+
return target;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Remove a registered task and its children: their task directories move to the trash and their registry entries go.
|
|
506
|
+
* Task numbers are never reused, so an old ID cannot come to name a new task. Worktrees and branches stay.
|
|
507
|
+
*/
|
|
508
|
+
async removeTask(id) {
|
|
509
|
+
const plan = await this.removal({ task: id });
|
|
510
|
+
const data = await this.read();
|
|
511
|
+
// Keep the highest removed number, so the store never hands it out again.
|
|
512
|
+
const tasksDirectory = dirname(dirname(data.tasks[id].record));
|
|
513
|
+
const numbers = plan.tasks.map(({ id: taskId }) => Number(/task-(\d+)$/.exec(taskId)?.[1] ?? 0));
|
|
514
|
+
const marker = join(tasksDirectory, LAST_TASK_NUMBER);
|
|
515
|
+
const recorded = Number((await readFile(marker, "utf8").catch(() => "0")).trim()) || 0;
|
|
516
|
+
await writeFile(marker, `${Math.max(recorded, ...numbers)}\n`);
|
|
517
|
+
const moved = [];
|
|
518
|
+
for (const { id: taskId } of plan.tasks) {
|
|
519
|
+
const directory = dirname(data.tasks[taskId].record);
|
|
520
|
+
const there = await stat(directory)
|
|
521
|
+
.then(() => true)
|
|
522
|
+
.catch(() => false);
|
|
523
|
+
if (there)
|
|
524
|
+
moved.push([await this.toTrash(directory, `${data.tasks[taskId].project}-${basename(directory)}`), directory]);
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
await this.update((current) => {
|
|
528
|
+
for (const { id: taskId } of plan.tasks) delete current.tasks[taskId];
|
|
529
|
+
});
|
|
530
|
+
} catch (error) {
|
|
531
|
+
for (const [trashed, directory] of moved) await rename(trashed, directory).catch(() => {});
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
return { ...plan, trash: moved.map(([trashed]) => trashed) };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Remove a project: its store (every task's record) moves to the trash, the project and its tasks leave the registry,
|
|
539
|
+
* and the project links in its repositories and children's worktrees go. Worktrees and branches stay.
|
|
540
|
+
*/
|
|
541
|
+
async removeProject(name) {
|
|
542
|
+
const plan = await this.removal({ project: name });
|
|
543
|
+
const { slug } = plan.project;
|
|
544
|
+
const project = (await this.read()).projects[slug];
|
|
545
|
+
const marker = join(taskRoot(project.store), LAST_TASK_NUMBER);
|
|
546
|
+
const highest = Math.max(
|
|
547
|
+
Number((await readFile(marker, "utf8").catch(() => "0")).trim()) || 0,
|
|
548
|
+
...plan.tasks.map(({ id }) => Number(/task-(\d+)$/.exec(id)?.[1] ?? 0)),
|
|
549
|
+
);
|
|
550
|
+
const trashed = await this.toTrash(project.store, `project-${slug}`);
|
|
551
|
+
try {
|
|
552
|
+
await this.update((current) => {
|
|
553
|
+
if (highest) current.retired = { ...current.retired, [slug]: Math.max(current.retired?.[slug] ?? 0, highest) };
|
|
554
|
+
delete current.projects[slug];
|
|
555
|
+
for (const { id } of plan.tasks) delete current.tasks[id];
|
|
556
|
+
});
|
|
557
|
+
} catch (error) {
|
|
558
|
+
await rename(trashed, project.store).catch(() => {});
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
const unlinked = [];
|
|
562
|
+
for (const directory of [...project.repos, ...plan.worktrees.map((worktree) => worktree.path)]) {
|
|
563
|
+
const link = join(directory, PROJECT_LINK);
|
|
564
|
+
const target = await readlink(link).catch(() => undefined);
|
|
565
|
+
if (target !== undefined && resolve(directory, target) === resolve(project.store)) {
|
|
566
|
+
await unlink(link).catch(() => {});
|
|
567
|
+
unlinked.push(link);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { ...plan, trash: [trashed], unlinked };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** What the trash holds, oldest first. */
|
|
574
|
+
async trashEntries() {
|
|
575
|
+
return (await readdir(this.trash).catch(() => [])).sort();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Delete everything in the trash. Only the user's own command calls this; agents never do (Task 006). */
|
|
579
|
+
async emptyTrash() {
|
|
580
|
+
const entries = await this.trashEntries();
|
|
581
|
+
await rm(this.trash, { recursive: true, force: true });
|
|
582
|
+
return entries;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Registered tasks whose primary directory contains cwd, deepest first. */
|
|
586
|
+
async here(cwd) {
|
|
587
|
+
const where = resolve(cwd);
|
|
588
|
+
return Object.entries((await this.read()).tasks)
|
|
589
|
+
.filter(([, task]) => {
|
|
590
|
+
const primary = task.environment?.primary;
|
|
591
|
+
return primary && (where === primary || where.startsWith(primary + sep));
|
|
592
|
+
})
|
|
593
|
+
.sort(([, a], [, b]) => b.environment.primary.length - a.environment.primary.length)
|
|
594
|
+
.map(([id, task]) => ({ id, task }));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Fan a task out into a child: create the child record, a Git worktree on a new branch for it, copy the parent
|
|
599
|
+
* repository's personal Freeflow config when the worktree has none, and make the worktree the child's directory.
|
|
600
|
+
* If the worktree cannot be created, the child stays registered with no environment and the error says so.
|
|
601
|
+
*/
|
|
602
|
+
async fanout(parent, title, goal, { path, base, cwd } = {}) {
|
|
603
|
+
const parentTask = await this.task(parent);
|
|
604
|
+
if (!parentTask) throw new RegistryError(`No task ${parent}.`);
|
|
605
|
+
const project = (await this.read()).projects[parentTask.project];
|
|
606
|
+
const repo = await this.repoRoot(parentTask.environment?.primary ?? cwd ?? process.cwd());
|
|
607
|
+
const created = await this.createTask(parentTask.project, title, goal, parent);
|
|
608
|
+
const taskDirectory = basename(dirname(created.record));
|
|
609
|
+
const childSlug = taskDirectory.replace(/^task-\d+-/, "");
|
|
610
|
+
const branch = `${parentTask.project}/${taskDirectory}`;
|
|
611
|
+
const target = path ? directory(path) : join(project.worktrees ?? dirname(repo), `${basename(repo)}-${childSlug}`);
|
|
612
|
+
try {
|
|
613
|
+
await execFile("git", ["-C", repo, "worktree", "add", "-b", branch, target, base ?? "HEAD"]);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
const reason = String(error?.stderr || error?.message || error).trim();
|
|
616
|
+
return {
|
|
617
|
+
...created,
|
|
618
|
+
branch,
|
|
619
|
+
error: `The child ${created.id} is registered, but its worktree was not created: ${reason}`,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const local = join(repo, ".freeflow", "local.json");
|
|
623
|
+
const copied = join(target, ".freeflow", "local.json");
|
|
624
|
+
const hasLocal = await stat(local)
|
|
625
|
+
.then(() => true)
|
|
626
|
+
.catch(() => false);
|
|
627
|
+
const hasCopy = await stat(copied)
|
|
628
|
+
.then(() => true)
|
|
629
|
+
.catch(() => false);
|
|
630
|
+
if (hasLocal && !hasCopy) {
|
|
631
|
+
await mkdir(dirname(copied), { recursive: true });
|
|
632
|
+
await copyFile(local, copied);
|
|
633
|
+
}
|
|
634
|
+
await this.setEnvironment(created.id, { primary: target });
|
|
635
|
+
await this.linkStore(target, project.store);
|
|
636
|
+
return { ...created, path: target, branch, copiedConfig: hasLocal && !hasCopy };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The records a search covers: one task, a task's tree (its root and the root's children), or a project. */
|
|
640
|
+
async scope(id, scope) {
|
|
641
|
+
const task = await this.task(id);
|
|
642
|
+
if (!task) throw new RegistryError(`No task ${id}.`);
|
|
643
|
+
if (scope === "task") return [[id, task.record]];
|
|
644
|
+
if (scope === "tree") {
|
|
645
|
+
const root = task.parent ?? id;
|
|
646
|
+
const rootTask = await this.task(root);
|
|
647
|
+
return [
|
|
648
|
+
...(rootTask ? [[root, rootTask.record]] : []),
|
|
649
|
+
...(await this.children(root)).map((child) => [child.id, child.task.record]),
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
if (scope === "project")
|
|
653
|
+
return (await this.projectTasks(task.project)).map((entry) => [entry.id, entry.task.record]);
|
|
654
|
+
throw new RegistryError("scope must be task, tree or project.");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export const MAX_HITS = 40;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lines matching a query, case-insensitively, in Working Records, each with the record's label and the heading and
|
|
7
|
+
* State of the entry it sits under. Read-only; reads each record on every call.
|
|
8
|
+
*/
|
|
9
|
+
export async function searchRecords(records, query) {
|
|
10
|
+
const needle = String(query ?? "")
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase();
|
|
13
|
+
if (!needle) throw new Error("Give a query.");
|
|
14
|
+
const hits = [];
|
|
15
|
+
for (const [label, path] of records) {
|
|
16
|
+
let text;
|
|
17
|
+
try {
|
|
18
|
+
text = await readFile(path, "utf8");
|
|
19
|
+
} catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
let heading = "(header)";
|
|
23
|
+
let state = "";
|
|
24
|
+
for (const line of text.split(/\r?\n/)) {
|
|
25
|
+
const entry = /^#{2,4} (.+)$/.exec(line);
|
|
26
|
+
if (entry) {
|
|
27
|
+
heading = entry[1];
|
|
28
|
+
state = "";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const field = /^- State: (\S+)/.exec(line);
|
|
32
|
+
if (field) state = field[1];
|
|
33
|
+
if (line.toLowerCase().includes(needle) && hits.length <= MAX_HITS)
|
|
34
|
+
hits.push(`- ${label} | ${heading}${state ? ` (${state})` : ""} | ${line.trim()}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!hits.length) return `No match for "${String(query).trim()}" in ${records.length} record(s).`;
|
|
38
|
+
const more = hits.length > MAX_HITS ? `\n(more than ${MAX_HITS} matches; narrow the query or scope)` : "";
|
|
39
|
+
return `${hits.slice(0, MAX_HITS).join("\n")}${more}`;
|
|
40
|
+
}
|