@aperant/framework 0.6.5 → 0.6.6
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/README.md +17 -251
- package/dist/cli/commands/commit.d.mts.map +1 -1
- package/dist/cli/commands/commit.mjs +25 -8
- package/dist/cli/commands/commit.mjs.map +1 -1
- package/dist/cli/commands/event.d.mts.map +1 -1
- package/dist/cli/commands/event.mjs +66 -0
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts.map +1 -1
- package/dist/cli/commands/modes.mjs +2 -1
- package/dist/cli/commands/modes.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +285 -139
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts.map +1 -1
- package/dist/cli/commands/triage.mjs +9 -5
- package/dist/cli/commands/triage.mjs.map +1 -1
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
- package/dist/cli/coordination/event-schema.d.mts +16 -0
- package/dist/cli/coordination/event-schema.d.mts.map +1 -0
- package/dist/cli/coordination/event-schema.mjs +161 -0
- package/dist/cli/coordination/event-schema.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +6 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +14 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/help.mjs +2 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/roadmap/conductor-view.d.mts +13 -0
- package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
- package/dist/cli/roadmap/conductor-view.mjs +31 -0
- package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
- package/dist/cli/route/skill-discover.d.mts.map +1 -1
- package/dist/cli/route/skill-discover.mjs +2 -1
- package/dist/cli/route/skill-discover.mjs.map +1 -1
- package/dist/cli/task/ids.d.mts +7 -4
- package/dist/cli/task/ids.d.mts.map +1 -1
- package/dist/cli/task/ids.mjs +11 -10
- package/dist/cli/task/ids.mjs.map +1 -1
- package/package.json +133 -125
- package/prompts/conductor-status-check.md +23 -0
- package/prompts/conductor-sub-agent.md +57 -0
- package/prompts/conductor-system.md +172 -0
- package/skills/apt-plan/SKILL.md +12 -0
- package/skills/apt-plan/adapters/conductor.md +98 -0
- package/skills/apt-setup/SKILL.md +2 -0
- package/skills/apt-ship/SKILL.md +27 -12
- package/skills/apt-spar/SKILL.md +290 -0
- package/src/cli/commands/commit.mjs +27 -8
- package/src/cli/commands/event.mjs +68 -0
- package/src/cli/commands/modes.mjs +2 -1
- package/src/cli/commands/task.mjs +305 -152
- package/src/cli/commands/triage.mjs +14 -5
- package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
- package/src/cli/coordination/event-schema.d.ts +13 -0
- package/src/cli/coordination/event-schema.mjs +174 -0
- package/src/cli/coordination/store.mjs +14 -0
- package/src/cli/help.mjs +2 -2
- package/src/cli/roadmap/conductor-view.d.ts +10 -0
- package/src/cli/roadmap/conductor-view.mjs +31 -0
- package/src/cli/route/skill-discover.mjs +2 -1
- package/src/cli/task/ids.mjs +15 -13
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the .mjs validator. Hand-authored because the
|
|
3
|
+
* import path points at src/.mjs directly (no dist roundtrip).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ValidateResult = { ok: true } | { ok: false; error: string }
|
|
7
|
+
|
|
8
|
+
export function validateConductorEvent(
|
|
9
|
+
op: string,
|
|
10
|
+
data: Record<string, unknown> | undefined | null,
|
|
11
|
+
): ValidateResult
|
|
12
|
+
|
|
13
|
+
export const CONDUCTOR_VALIDATED_OPS: ReadonlySet<string>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal
|
|
3
|
+
* event-schema.mjs — single source of truth for conductor event payload shapes.
|
|
4
|
+
*
|
|
5
|
+
* Why this lives in `framework`, not `core`:
|
|
6
|
+
* `packages/core` depends on `@aperant/framework`. The schema is shared by
|
|
7
|
+
* the CLI write path (`commands/event.mjs`), the framework lifecycle write
|
|
8
|
+
* seam (`coordination/store.mjs:appendEvent`), and the core IPC write seam
|
|
9
|
+
* (`packages/core/src/runtime/orchestration/conductor/event-log.ts`).
|
|
10
|
+
* Putting it in the lower layer keeps a single rule table that every
|
|
11
|
+
* writer can import.
|
|
12
|
+
*
|
|
13
|
+
* Shape: zero-dep plain JS validator returning a `{ ok, error? }` result.
|
|
14
|
+
* We deliberately avoid zod here so `.mjs` callers (event.mjs, store.mjs)
|
|
15
|
+
* don't need a build step, and so the core TS writer can use the same
|
|
16
|
+
* identity-shaped result without translating between zod and a custom
|
|
17
|
+
* error type.
|
|
18
|
+
*
|
|
19
|
+
* Per Codex roundtable lock 2026-05-15:
|
|
20
|
+
* - `phase.changed` is reserved for PTY busy/idle transitions ONLY.
|
|
21
|
+
* Plan completion uses `artifact.ready{kind:'plan'}`, not phase.changed.
|
|
22
|
+
* - `awaiting-input` / `input-rescinded` MUST carry both terminal_id and
|
|
23
|
+
* request_id so the drawer can pair cards strictly.
|
|
24
|
+
* - `artifact.ready{kind:'plan'}` is the canonical plan-completion signal
|
|
25
|
+
* consumed by the conductor's reviewer queue (Step 5's reviewPlan tool).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Conductor ops the schema enforces. Other ops pass through unchanged. */
|
|
29
|
+
const CONDUCTOR_OPS = new Set([
|
|
30
|
+
'phase.changed',
|
|
31
|
+
'artifact.ready',
|
|
32
|
+
'awaiting-input',
|
|
33
|
+
'input-rescinded',
|
|
34
|
+
'heartbeat',
|
|
35
|
+
'budget.exceeded',
|
|
36
|
+
'terminal.enrolled',
|
|
37
|
+
'terminal.unenrolled',
|
|
38
|
+
'conductor.intent',
|
|
39
|
+
'conductor.cross_check',
|
|
40
|
+
'conductor.realignment',
|
|
41
|
+
// c-4 (action-capable) — gate verdict + boot-replay audit trail
|
|
42
|
+
'conductor.action.executed',
|
|
43
|
+
'conductor.action.gate_blocked',
|
|
44
|
+
'conductor.boot_reconciled',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
/** Allowed values for `phase.changed.to` — PTY busy/idle only. */
|
|
48
|
+
const ALLOWED_PHASE_VALUES = new Set(['busy', 'idle'])
|
|
49
|
+
|
|
50
|
+
/** Allowed values for `artifact.ready.kind`. */
|
|
51
|
+
const ALLOWED_ARTIFACT_KINDS = new Set(['plan', 'spec', 'build', 'review'])
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate the data payload of a conductor event against the op's contract.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} op
|
|
57
|
+
* @param {Record<string, unknown> | undefined | null} data
|
|
58
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function validateConductorEvent(op, data) {
|
|
61
|
+
if (!CONDUCTOR_OPS.has(op)) return { ok: true }
|
|
62
|
+
const d = data && typeof data === 'object' ? data : {}
|
|
63
|
+
|
|
64
|
+
switch (op) {
|
|
65
|
+
case 'awaiting-input':
|
|
66
|
+
case 'input-rescinded':
|
|
67
|
+
return requireFields(op, d, ['terminal_id', 'request_id'])
|
|
68
|
+
|
|
69
|
+
case 'phase.changed': {
|
|
70
|
+
const r = requireFields(op, d, ['from', 'to', 'terminal_id', 'request_id'])
|
|
71
|
+
if (!r.ok) return r
|
|
72
|
+
if (!ALLOWED_PHASE_VALUES.has(String(d.to))) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `phase.changed.to must be one of [${[...ALLOWED_PHASE_VALUES].join(', ')}], got: ${String(
|
|
76
|
+
d.to,
|
|
77
|
+
)}. Plan completion must use artifact.ready{kind:'plan'}.`,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'artifact.ready': {
|
|
84
|
+
const r = requireFields(op, d, [
|
|
85
|
+
'kind',
|
|
86
|
+
'path',
|
|
87
|
+
'hash',
|
|
88
|
+
'request_id',
|
|
89
|
+
'terminal_id',
|
|
90
|
+
'task_id',
|
|
91
|
+
])
|
|
92
|
+
if (!r.ok) return r
|
|
93
|
+
if (!ALLOWED_ARTIFACT_KINDS.has(String(d.kind))) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `artifact.ready.kind must be one of [${[...ALLOWED_ARTIFACT_KINDS].join(
|
|
97
|
+
', ',
|
|
98
|
+
)}], got: ${String(d.kind)}`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: true }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'conductor.intent':
|
|
105
|
+
return requireFields(op, d, ['terminal_id', 'request_id', 'action', 'reasoning'])
|
|
106
|
+
|
|
107
|
+
case 'conductor.cross_check':
|
|
108
|
+
return requireFields(op, d, ['terminal_id', 'request_id'])
|
|
109
|
+
|
|
110
|
+
case 'conductor.realignment': {
|
|
111
|
+
const r = requireFields(op, d, ['terminal_id', 'request_id_orig'])
|
|
112
|
+
if (!r.ok) return r
|
|
113
|
+
if (d.attempt_n === undefined || d.attempt_n === null)
|
|
114
|
+
return { ok: false, error: `${op} missing required field(s): attempt_n` }
|
|
115
|
+
return { ok: true }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case 'terminal.enrolled':
|
|
119
|
+
case 'terminal.unenrolled':
|
|
120
|
+
case 'heartbeat':
|
|
121
|
+
case 'budget.exceeded':
|
|
122
|
+
return requireFields(op, d, ['terminal_id'])
|
|
123
|
+
|
|
124
|
+
case 'conductor.action.executed':
|
|
125
|
+
return requireFields(op, d, ['terminal_id', 'request_id', 'action_kind', 'idempotency_key'])
|
|
126
|
+
|
|
127
|
+
case 'conductor.action.gate_blocked':
|
|
128
|
+
return requireFields(op, d, [
|
|
129
|
+
'terminal_id',
|
|
130
|
+
'request_id',
|
|
131
|
+
'action_kind',
|
|
132
|
+
'idempotency_key',
|
|
133
|
+
'blocked_reason',
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
case 'conductor.boot_reconciled': {
|
|
137
|
+
// Fleet-wide event — numeric fields only. All optional but if
|
|
138
|
+
// present must be finite numbers (zero allowed).
|
|
139
|
+
const numFields = ['cleaned_leases', 'replayed', 'start_offset']
|
|
140
|
+
for (const f of numFields) {
|
|
141
|
+
if (d[f] === undefined) continue
|
|
142
|
+
if (typeof d[f] !== 'number' || !Number.isFinite(d[f])) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: `${op} field ${f} must be a finite number, got: ${String(d[f])}`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { ok: true }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
default:
|
|
153
|
+
return { ok: true }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Set of ops the schema validates — exported for test enumeration. */
|
|
158
|
+
export const CONDUCTOR_VALIDATED_OPS = CONDUCTOR_OPS
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} op
|
|
162
|
+
* @param {Record<string, unknown>} d
|
|
163
|
+
* @param {string[]} fields
|
|
164
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
165
|
+
*/
|
|
166
|
+
function requireFields(op, d, fields) {
|
|
167
|
+
const missing = fields.filter((f) => {
|
|
168
|
+
const v = d[f]
|
|
169
|
+
if (typeof v !== 'string' || v.length === 0) return true
|
|
170
|
+
return false
|
|
171
|
+
})
|
|
172
|
+
if (missing.length === 0) return { ok: true }
|
|
173
|
+
return { ok: false, error: `${op} missing required field(s): ${missing.join(', ')}` }
|
|
174
|
+
}
|
|
@@ -32,6 +32,7 @@ import { withFileLock } from '../util/fs-lock.mjs'
|
|
|
32
32
|
import { atomicWriteJson } from '../util/io.mjs'
|
|
33
33
|
import { getAgentIdentity, resolveSharedRoot } from './agent-identity.mjs'
|
|
34
34
|
import { appendLifecycleEvent } from './event-log.mjs'
|
|
35
|
+
import { validateConductorEvent } from './event-schema.mjs'
|
|
35
36
|
import {
|
|
36
37
|
listActive as teamStatusListActive,
|
|
37
38
|
removeSelf as teamStatusRemoveSelf,
|
|
@@ -128,9 +129,22 @@ export function createCoordinationStore(projectRoot) {
|
|
|
128
129
|
* @internal — R10 append-only event log. Emits one JSONL line per
|
|
129
130
|
* call via appendLifecycleEvent. Best-effort: filesystem errors are
|
|
130
131
|
* swallowed so a missing events/ dir never breaks task lifecycle.
|
|
132
|
+
*
|
|
133
|
+
* Step 1 — schema gate: conductor ops written via the store seam are
|
|
134
|
+
* validated against `event-schema.mjs` before they reach disk.
|
|
135
|
+
* Schema violations throw (caught here and surfaced via console.warn)
|
|
136
|
+
* because a malformed conductor event would silently break the
|
|
137
|
+
* drawer's pairing logic — we want it loud, not best-effort.
|
|
131
138
|
*/
|
|
132
139
|
appendEvent(evt) {
|
|
133
140
|
try {
|
|
141
|
+
if (evt && typeof evt.op === 'string') {
|
|
142
|
+
const validation = validateConductorEvent(evt.op, evt.data)
|
|
143
|
+
if (!validation.ok) {
|
|
144
|
+
console.warn(`[store.appendEvent] schema violation: ${validation.error}`)
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
134
148
|
return appendLifecycleEvent(projectRoot, evt)
|
|
135
149
|
} catch {
|
|
136
150
|
/* best-effort — never block a lifecycle transition on the log */
|
package/src/cli/help.mjs
CHANGED
|
@@ -28,7 +28,7 @@ Commands:
|
|
|
28
28
|
task create <dir> --description <text> [--scope <s>] [--milestone <id>] [--phase <id>] [--roadmap-ref-url <url>] [--depends-on <refs>] [--track <T>] [--autonomy <N>] Create isolated task
|
|
29
29
|
task list <dir> [--scope <s>] [--blocked-by <id>] List active tasks (optionally filtered)
|
|
30
30
|
task get <dir> --id <task-id> Get task details and artifacts
|
|
31
|
-
task update <dir> --id <id> [--lifecycle-phase <p>] [--subtasks-total <N>] [--depends-on <refs>] Update non-linkage task state
|
|
31
|
+
task update <dir> --id <id> [--lifecycle-phase <p>] [--pr-url <url>] [--subtasks-total <N>] [--depends-on <refs>] Update non-linkage task state; --pr-url may arm ci-watch per ci_watch.after_ship
|
|
32
32
|
task move <dir> --id <id> [--to-scope <s>] [--to-milestone <id>] [--to-phase <id>] [--to-roadmap-ref-url <url>] [--reason <text>] [--force] Reassign scope/milestone/phase with history
|
|
33
33
|
task close <dir> --id <task-id> [--verdict <v>] Close task and move to completed
|
|
34
34
|
task narration-drain <dir> --task <task-id> Remove a row from state.pending_narration[] under withFileLock (called by /apt:close-task --narrate-only after a successful narrator spawn)
|
|
@@ -131,5 +131,5 @@ Flags:
|
|
|
131
131
|
|
|
132
132
|
All output is JSON.`
|
|
133
133
|
|
|
134
|
-
process.stdout.write(help
|
|
134
|
+
process.stdout.write(`${help}\n`)
|
|
135
135
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the .mjs conductor-view helper.
|
|
3
|
+
* Hand-authored — the import path points at src/.mjs directly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ConductorRoadmapView {
|
|
7
|
+
scopes: Array<{ scope: string; roadmap: unknown }>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildConductorRoadmapView(projectRoot: string): ConductorRoadmapView
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conductor-view.mjs — read-only roadmap surface for the Aperant
|
|
3
|
+
* Conductor's `getRoadmap` tool.
|
|
4
|
+
*
|
|
5
|
+
* Wraps `loadRoadmapForScope` + `listRoadmapScopes` so the Conductor sees
|
|
6
|
+
* the same overlay-aware view every framework command sees. Direct reads
|
|
7
|
+
* of `.aperant/roadmap/*\/phases/*.json` or `milestones.json` would miss
|
|
8
|
+
* the dual-write overlay (seed_id from legacy blob, etc.) — Codex
|
|
9
|
+
* roundtable correction #3.
|
|
10
|
+
*
|
|
11
|
+
* Step 5 of Conductor v2 ship order.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadRoadmapForScope } from './io.mjs'
|
|
15
|
+
import { listRoadmapScopes } from './paths.mjs'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a flat per-scope roadmap snapshot for the Conductor.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} projectRoot
|
|
21
|
+
* @returns {{ scopes: Array<{ scope: string, roadmap: unknown }> }}
|
|
22
|
+
*/
|
|
23
|
+
export function buildConductorRoadmapView(projectRoot) {
|
|
24
|
+
const scopes = listRoadmapScopes(projectRoot)
|
|
25
|
+
const out = []
|
|
26
|
+
for (const scope of scopes) {
|
|
27
|
+
const roadmap = loadRoadmapForScope(projectRoot, scope)
|
|
28
|
+
if (roadmap !== null) out.push({ scope, roadmap })
|
|
29
|
+
}
|
|
30
|
+
return { scopes: out }
|
|
31
|
+
}
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from 'node:fs'
|
|
30
30
|
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
31
31
|
import { parse as parseYaml } from 'yaml'
|
|
32
|
+
import { sanitizeAptVersionForYamlParse } from '../install/version-header.mjs'
|
|
32
33
|
import { SkillFrontmatterSchema } from '../skill-author/contract.mjs'
|
|
33
34
|
|
|
34
35
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/
|
|
@@ -126,7 +127,7 @@ function readSkill(file) {
|
|
|
126
127
|
}
|
|
127
128
|
let parsed
|
|
128
129
|
try {
|
|
129
|
-
parsed = parseYaml(m[1])
|
|
130
|
+
parsed = parseYaml(sanitizeAptVersionForYamlParse(m[1]))
|
|
130
131
|
} catch (err) {
|
|
131
132
|
return {
|
|
132
133
|
ok: false,
|
package/src/cli/task/ids.mjs
CHANGED
|
@@ -9,8 +9,7 @@ import { join, resolve } from 'node:path'
|
|
|
9
9
|
* Supported formats: YY-MM-DD (default, sorts chronologically), DD-MM-YY, MM-DD-YY
|
|
10
10
|
* Uses hyphens as separators for filesystem safety.
|
|
11
11
|
*/
|
|
12
|
-
export function formatDateForTask(dateFormat) {
|
|
13
|
-
const now = new Date()
|
|
12
|
+
export function formatDateForTask(dateFormat, now = new Date()) {
|
|
14
13
|
const yy = String(now.getFullYear()).slice(2)
|
|
15
14
|
const mm = String(now.getMonth() + 1).padStart(2, '0')
|
|
16
15
|
const dd = String(now.getDate()).padStart(2, '0')
|
|
@@ -27,20 +26,23 @@ export function formatDateForTask(dateFormat) {
|
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Generate a short task ID from description + timestamp.
|
|
30
|
-
* Format: {
|
|
31
|
-
*
|
|
29
|
+
* Format: {slug}_{date}
|
|
30
|
+
* Slug comes first so task lists scan by verb instead of by date.
|
|
32
31
|
* Date format is configurable via preferences.date_format in config.
|
|
32
|
+
* Same-day same-slug collisions are disambiguated by the caller
|
|
33
|
+
* (see commands/task.mjs) via a numeric -2, -3, … suffix; this
|
|
34
|
+
* generator never emits a counter or a random hash.
|
|
33
35
|
*/
|
|
34
|
-
export function generateTaskId(description, config) {
|
|
35
|
-
const slug =
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
export function generateTaskId(description, config, now = new Date()) {
|
|
37
|
+
const slug =
|
|
38
|
+
description
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
41
|
+
.slice(0, 40)
|
|
42
|
+
.replace(/^-|-$/g, '') || 'task'
|
|
40
43
|
const dateFormat = config?.preferences?.date_format || 'YY-MM-DD'
|
|
41
|
-
const date = formatDateForTask(dateFormat)
|
|
42
|
-
|
|
43
|
-
return `${date}_${slug}-${hash}`
|
|
44
|
+
const date = formatDateForTask(dateFormat, now)
|
|
45
|
+
return `${slug}_${date}`
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export function getTaskDir(projectDir, taskId) {
|